Android Datastore 动态创建与源码解析

涉及到的知识点

1、协程原理---->很好的博客介绍,一个小故事讲明白进程、线程、Kotlin 协程到底啥关系?
2、Channel知识点---->Android—kotlin-Channel超详细讲解
3、Coroutines : CompletableDeferred and structured concurrency

封装的DataStoreUtils工具—>gitHub

本篇博客目的

公司使用SharedPreferences容易导致ANR,调研能否使用DataStore替换公司目前的SharedPreferences解决ANR问题,所以需要先研究一下源码

目录
  • 版本引入
  • 迁移SharedPreferences数据到dataStore
  • 动态创建DataStore
  • 存储参数
  • 总结
版本引入
implementation "androidx.datastore:datastore-preferences:1.0.0"
迁移SharedPreferences数据到dataStore

既然是迁移数据,那么需要将SharedPreferences已存储的数据迁移到dataStore,所以需要先构建dataStore。
目前网上构建迁移DataStore的案例Demo如下

//迁移使用
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "userSharePreFile",produceMigrations = { context ->listOf(SharedPreferencesMigration(context,"userSharePreFile"))}
)//或 
//这种构建DataStore写法是alpha版本有的,在1.0.0版本就找不到了
var dataStore: DataStore<Preferences> = context.createDataStore(name = "userSharePreFile")
//或
//直接构建
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "userSharePreFile"
)

上面3种写法都是对Context进行扩展创建的DataStore,所以上面创建的方式,都有一个缺点,就是需要提前知道name才能创建,如果你之前创建SharedPreferences的方式,是通过外部传递进来name构建的话,上面直接创建DataStore方式就显然不适合你了。

翻阅旧版本(alpha版本)源码,一探究竟如何构建DataStore
//alpha版本构建方式
var dataStore: DataStore<Preferences> = context.createDataStore(name = "userSharePreFile")fun Context.createDataStore(name: String,corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,//①migrations: List<DataMigration<Preferences>> = listOf(),//②scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
): DataStore<Preferences> =PreferenceDataStoreFactory.create(//③produceFile = {File(this.filesDir, "datastore/$name.preferences_pb")},corruptionHandler = corruptionHandler,migrations = migrations,scope = scope)

可以明显看到是使用PreferenceDataStoreFactory.create返回DataStore
① 是构建需要迁移SharedPreferences文件名称
② 指明协程是在IO运行
③ 新文件存储的位置
再看看另外一种通过 by preferencesDataStore 创建DataStore方式

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "userSharePreFile"
)public fun preferencesDataStore(name: String,corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,//①produceMigrations: (Context) -> List<DataMigration<Preferences>> = { listOf() },//②scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
): ReadOnlyProperty<Context, DataStore<Preferences>> {return PreferenceDataStoreSingletonDelegate(name, corruptionHandler, produceMigrations, scope)
}internal class PreferenceDataStoreSingletonDelegate internal constructor(private val name: String,private val corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,private val produceMigrations: (Context) -> List<DataMigration<Preferences>>,private val scope: CoroutineScope
) : ReadOnlyProperty<Context, DataStore<Preferences>> {private val lock = Any()@GuardedBy("lock")@Volatileprivate var INSTANCE: DataStore<Preferences>? = nulloverride fun getValue(thisRef: Context, property: KProperty<*>): DataStore<Preferences> {return INSTANCE ?: synchronized(lock) {if (INSTANCE == null) {val applicationContext = thisRef.applicationContextINSTANCE = PreferenceDataStoreFactory.create(corruptionHandler = corruptionHandler,migrations = produceMigrations(applicationContext),scope = scope) {applicationContext.preferencesDataStoreFile(name)}}INSTANCE!!}}
}//文件存储位置
public fun Context.preferencesDataStoreFile(name: String): File =this.dataStoreFile("$name.preferences_pb")

题外话:这里有利用kotlin委托属性by关键字语法
① 需要迁移的SharedPreferences文件
② 协程运行在IO

可以看出旧版本(alpha) 与 by preferencesDataStore 2种方案,都最终通过PreferenceDataStoreFactory.create,返回DataStore,我们就继续再看看PreferenceDataStoreFactory.kt的具体实现逻辑

//PreferenceDataStoreFactory.ktpublic fun create(corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,//迁移的share文件集合migrations: List<DataMigration<Preferences>> = listOf(),//IOscope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),//dataStore文件存储的目录位置produceFile: () -> File ): DataStore<Preferences> {val delegate = DataStoreFactory.create(//创建SingleProcessDataStoreserializer = PreferencesSerializer,corruptionHandler = corruptionHandler,migrations = migrations,scope = scope) {//省略代码} //传入SingleProcessDataStorereturn PreferenceDataStore(delegate)}//这里有主动的去调用updateData 方法,如果不去主动调用,就不会触发迁移的逻辑
//下文的扩展函数DataStore<Preferences>.edit会说到这里
internal class PreferenceDataStore(private val delegate: DataStore<Preferences>) :DataStore<Preferences> by delegate {override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences):Preferences {return delegate.updateData {val transformed = transform(it)(transformed as MutablePreferences).freeze()transformed}}
}

继续看DataStoreFactory.create

//DataStoreFactory.kt
fun <T> create(produceFile: () -> File,serializer: Serializer<T>,corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,migrations: List<DataMigration<T>> = listOf(),scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())): DataStore<T> =//找到最终创建的类SingleProcessDataStore(produceFile = produceFile,serializer = serializer,corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(),initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)),scope = scope)

到目前为止已经知道真相了,最终是通过SingleProcessDataStore返回DataStore。

下面我们通过一张图片来小结一下,旧版本alpha版本的创建与新版本 by preferencesDataStore的调用逻辑链

DataStore.jpg

好,已经知道这么多了,那么我们就开始动态构建DataStore

动态创建DataStore
 fun preferencesMigrationDataStore(sharedPreferName: String) {val dataStore = PreferenceDataStoreFactory.create(corruptionHandler =  ReplaceFileCorruptionHandler<Preferences>(produceNewData = { emptyPreferences() }),//需要迁移的sharePrefer文件的名称migrations = listOf(SharedPreferencesMigration(mContext, sharedPreferName)),//IOscope = CoroutineScope(Dispatchers.IO + SupervisorJob())) {//dataStore文件名称mContext.preferencesDataStoreFile(sharedPreferName)}runBlocking {//必须要执行这行代码,否是不会走迁移的逻辑dataStore.updateData {it.toPreferences()}}}

migrations:表示你要迁移的sharedPreference文件
scope :表示写数据是在IO
执行完上述代码后,.xml就会消失,然后会在files目录下多出一个/datastore/xxx.preferences_pb文件
切勿重复对某个SharedPreferences执行文件迁移方案,否则会报错。比如你前一秒在执行迁移,后一秒又继续执行迁移
SharedPrefs.png
dataStore_migrate.jpg

####存储参数

/*** @key 参数* @value 具体的值*/private fun putInt(key:String, value: Int) {runBlocking {dataStore.edit {//①it[intPreferencesKey(key)] = value}}}
//类似的还有如下,这些都是google提供的参数
intPreferencesKey
doublePreferencesKey
stringPreferencesKey
....

看①详情,点击edit,发现他是一个扩展函数

public suspend fun DataStore<Preferences>.edit(transform: suspend (MutablePreferences) -> Unit
): Preferences {return this.updateData {//调用的是PreferenceDataStore.updateData()//it.toMutablePreferences() 返回类似mapit.toMutablePreferences().apply { transform(this) }}
}

transform 就是调用者{}里面的内容,接下来我们看看 PreferenceDataStore 类的代码

//由前部分的代码,可以得知,delegate = SingleProcessDataStore 
internal class PreferenceDataStore(private val delegate: DataStore<Preferences>) :DataStore<Preferences> by delegate {override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences):Preferences {return delegate.updateData {//调用SingleProcessDataStore.updateData //返回给上一个{}也就是  it.toMutablePreferences().apply { transform(this) }val transformed = transform(it)(transformed as MutablePreferences).freeze()transformed //拿到用户的需要更改的内容数据}}
}

代码里调用了delegate.updateData(), 所以继续看SingleProcessDataStore的updateData

SingleProcessDataStore.ktoverride suspend fun updateData(transform: suspend (t: T) -> T): T {val ack = CompletableDeferred<T>()val currentDownStreamFlowState = downstreamFlow.value//协程体封装进Message.Update,coroutineContext 是协程的上下文,就是我们的 runBlocking 启动的线程,我这里是mainval updateMsg = Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)//对消息进行分发,他的类是 SimpleActoractor.offer(updateMsg)//这里会拿到Preferences,如何拿?后面会有一个update.ack.completeWith方法,会返回回来return ack.await()}
internal class SimpleActor<T>(private val scope: CoroutineScope,//Dispatchers.IO + SupervisorJob()onComplete: (Throwable?) -> Unit,onUndeliveredElement: (T, Throwable?) -> Unit,private val consumeMessage: suspend (T) -> Unit
) {private val messageQueue = Channel<T>(capacity = UNLIMITED)private val remainingMessages = AtomicInteger(0)//......  省去//这里就是将刚刚封装的消息体,添加进这里fun offer(msg: T) {check(//发送封装的消息体messageQueue.trySend(msg).onClosed { throw it ?: ClosedSendChannelException("Channel was closed normally") }.isSuccess)if (remainingMessages.getAndIncrement() == 0) {scope.launch {check(remainingMessages.get() > 0)do {// scope = Dispatchers.IO + SupervisorJob()scope.ensureActive()//取出封装的消息体,然后进行任务处理consumeMessage(messageQueue.receive())} while (remainingMessages.decrementAndGet() != 0)}}}
}

tip:这里有利用Channel进行协程通信,Channel是可以处理并发的情况
到这里,我们可以知道,我们由runBlocking(main主线程) 协程 到 Dispatchers.IO的任务分发

private val actor = SimpleActor<Message<T>>(scope = scope,// CoroutineScope(Dispatchers.IO + SupervisorJob())onComplete = {//.....省略},onUndeliveredElement = { msg, ex ->//.....省略) { msg ->//处理分发的任务,msg 为刚刚封装的updateMsg when (msg) { is Message.Read -> {//读取handleRead(msg)}is Message.Update -> {//更新handleUpdate(msg)}}}
 private suspend fun handleUpdate(update: Message.Update<T>) {update.ack.completeWith(runCatching {when (val currentState = downstreamFlow.value) {is Data -> {//写数据到filetransformAndWrite(update.transform, update.callerContext)}is ReadException, is UnInitialized -> {if (currentState === update.lastState) {           //读取file文件      ①          readAndInitOrPropagateAndThrowFailure()//写数据到file       ②transformAndWrite(update.transform, update.callerContext)} else {throw (currentState as ReadException).readException}}is Final -> throw currentState.finalException // won't happen}})}

第一次使用 downstreamFlow.value = UnInitialized 。
这里要注意一下update.ack.completeWith这个函数,他是拿到结果成功返回

这里再次展示出来,是告诉大家,在哪里会等待结果返回override suspend fun updateData(transform: suspend (t: T) -> T): T {val ack = CompletableDeferred<T>()val currentDownStreamFlowState = downstreamFlow.valueval updateMsg =Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)actor.offer(updateMsg)return ack.await() //这里就是等待 update.ack.completeWith的结果返回,所以如果不加这行,是不会卡主线程的}

所以使用runBlocking是会卡主线程的,如果你还有UI刷新情况,严重的情况会导致ANR问题

不扯之前的了,我们继续继续,看① 的读取

 private suspend fun readAndInitOrPropagateAndThrowFailure() {try {readAndInit()} catch (throwable: Throwable) {downstreamFlow.value = ReadException(throwable)throw throwable}}private suspend fun readAndInit() {check(downstreamFlow.value == UnInitialized || downstreamFlow.value is ReadException)//这个是锁,协程里面专有的,详情可以看 https://www.kotlincn.net/docs/reference/coroutines/shared-mutable-state-and-concurrency.htmlval updateLock = Mutex()//读取dataStore文件var initData = readDataOrHandleCorruption()var initializationComplete: Boolean = false//这里就是shareprefence转dataStoreval api = object : InitializerApi<T> {override suspend fun updateData(transform: suspend (t: T) -> T): T {return updateLock.withLock() {if (initializationComplete) {throw IllegalStateException("InitializerApi.updateData should not be " +"called after initialization is complete.")}//transform里面就是去迁移数据的方法val newData = transform(initData)//这里有做,新 旧值比较,如果不同,就去写入if (newData != initData) {//写文件writeData(newData)initData = newData}initData}}}//initTasks 里面装的就是需要转换的 SharedPreferences集合initTasks?.forEach { it(api) }initTasks = nullupdateLock.withLock {initializationComplete = true}//这里有将迁移完成后的数据,存储在flow.value里面downstreamFlow.value = Data(initData, initData.hashCode())}//读取dataStore文件
private suspend fun readDataOrHandleCorruption(): T {try {return readData()} catch (ex: CorruptionException) {val newData: T = corruptionHandler.handleCorruption(ex)try {writeData(newData)} catch (writeEx: IOException) {ex.addSuppressed(writeEx)throw ex}return newData}}private suspend fun readData(): T {try {FileInputStream(file).use { stream ->return serializer.readFrom(stream)}} catch (ex: FileNotFoundException) {if (file.exists()) {throw ex}return serializer.defaultValue}}

file就是我们存储的dataStore,目录是在 “datastore/$name.preferences_pb”

看完了①,再来看看② 写入数据到file,写数据的方法是 transformAndWrite()

//....
transformAndWrite(update.transform, update.callerContext)
//...private suspend fun transformAndWrite(//来源于 Message.Update.transform封装transform: suspend (t: T) -> T,//来源于 Message.Update.callerContext封装callerContext: CoroutineContext): T {val curDataAndHash = downstreamFlow.value as Data<T>curDataAndHash.checkHashCode()val curData = curDataAndHash.value//这里callerContext  就是我们的 runBlocking,main(主线程)//这里是将旧的值给回调用者,然后从调用者获取到新参数val newData = withContext(callerContext) { transform(curData) }curDataAndHash.checkHashCode()//这里有做数据比较return if (curData == newData) {curData} else {//写入数据writeData(newData)//保存到flow.value里面downstreamFlow.value = Data(newData, newData.hashCode())newData}}private val SCRATCH_SUFFIX = ".tmp"
//写入数据
internal suspend fun writeData(newData: T) {file.createParentDirectories()//这里创建出来的文件是"datastore/$name.preferences_pb.tmp"val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX)try {FileOutputStream(scratchFile).use { stream ->serializer.writeTo(newData, UncloseableOutputStream(stream))stream.fd.sync()}//重新命名回去file,这里的file是我们目标的文件dataStore名称if (!scratchFile.renameTo(file)) {//重新命名失败,抛出异常throw IOException("Unable to rename $scratchFile." +"This likely means that there are multiple instances of DataStore " +"for this file. Ensure that you are only creating a single instance of " +"datastore for this file.")}} catch (ex: IOException) {if (scratchFile.exists()) {scratchFile.delete() }throw ex}}

到此,更新值的操作,我们已经全部走完了流程

总结

1、文件的写入是发生在IO层面
2、使用runBlocking是会卡主线程,如果此时存在需要刷新UI的情况,严重会ANR


/*** @key 参数* @value 具体的值*/private fun putInt(key:String, value: Int) {runBlocking {dataStore.edit {it[intPreferencesKey(key)] = value}}}public suspend fun DataStore<Preferences>.edit(transform: suspend (MutablePreferences) -> Unit
): Preferences {return this.updateData {it.toMutablePreferences().apply { transform(this) }}
}//更新逻辑private suspend fun handleUpdate(update: Message.Update<T>) {update.ack.completeWith(//通知结果回调//.....省去)}//transform 就是上面的{}里面的内容override suspend fun updateData(transform: suspend (t: T) -> T): T {val ack = CompletableDeferred<T>()val currentDownStreamFlowState = downstreamFlow.valueval updateMsg =Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)actor.offer(updateMsg)return ack.await() //这里就是等待 update.ack.completeWith的结果返回,所以如果不加这行,是不会卡主线程的//题外话不加ack.await() 代码也会执行}

所以,可以考虑使用withContext(IO){读取/更新等待操作}

3、更新参数的时候,是会跟旧的值比较,如果值相同就不写入了,否则就写入到文件里面,并且更新flow.value的值

 return if (curData == newData) {curData} else {writeData(newData)downstreamFlow.value = Data(newData, newData.hashCode())newData}

4、解决并发问题,使用channel解决协程之间沟通与并发,单线程的IO更新文件与并发

5、如果已将SharedPreference迁移到DataStore,你就不要继续使用SharedPreferences了,如果继续使用SharedPreferences,会与DataStore的值不同了

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/133837.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

数学建模比赛中常用的建模提示词(数模prompt)

以下为数学建模比赛中常用的建模提示词&#xff0c;希望对你有所帮助&#xff01; 帮我总结一下数学建模有哪些预测类算法&#xff1f; 灰色预测模型级比检验是什么意思? 描述一下BP神经网络算法的建模步骤 对于分类变量与分类变量相关性分析用什么算法 前10年的数据分别是1&a…

代码随想录 Day38 完全背包问题 LeetCode T70 爬楼梯 T322 零钱兑换 T279 完全平方数

前言 在今天的题目开始之前,让我们来回顾一下之前的知识,动规五部曲 1.确定dp数组含义 2.确定dp数组的递推公式 3.初始化dp数组 4.确定遍历顺序 5.打印dp数组来排错 tips: 1.当求取物品有限的时候用0-1背包,求取物品无限的时候用完全背包 结果是排列还是组合也有说法,当结果是组…

渗透实战靶机2wp

0x00 简介 1、测试环境 目标IP&#xff1a;10.xxxx 测试IP&#xff1a;192.168.139.128 测试环境&#xff1a;win10、kali等 测试时间&#xff1a;2021.7.22-2021.7.22 测试人员&#xff1a;ruanruan 2、测试过程 本次实战主要通过对收集到的端口、目录等信息进行持续整…

润和软件HopeStage与奇安信网神终端安全管理系统、可信浏览器完成产品兼容性互认证

近日&#xff0c;江苏润和软件股份有限公司&#xff08;以下简称“润和软件”&#xff09;HopeStage 操作系统与奇安信网神信息技术&#xff08;北京&#xff09;股份有限公司&#xff08;以下简称“奇安信”&#xff09;终端安全管理系统、可信浏览器完成产品兼容性测试。 测试…

阿里云二级域名绑定与宝塔Nginx反向代理配置

在阿里或者腾讯...各大域名商买好域名&#xff0c;备案解析好&#xff0c;目标URL&#xff0c;是真正的地址&#xff0c;比如一些端口&#xff0c;后者会自动填写。 注意ssl配置好&#xff0c;这里不要带反代端口

vue中异步更新$nextTick

1.需求 编辑标题, 编辑框自动聚焦 点击编辑&#xff0c;显示编辑框让编辑框&#xff0c;立刻获取焦点 2.代码实现 <template><div class"app"><div v-if"isShowEdit"><input type"text" v-model"editValue"…

王道p18 第12题假设 A中的 n个元素保存在一个一维数组中,请设计一个尽可能高效的算法,找出A的主元素。若存在主元素,则输出该元素:否则输出-1

视频讲解在&#xff1a;&#x1f447; p18 第12题 c语言实现王道数据结构课后习题_哔哩哔哩_bilibili 从前向后扫描数组元素&#xff0c;标记出一个可能成为主元素的元素 Num。然后重新计数&#xff0c;确认 Num 是否是主元素。 我们可分为以下两步: 1.选取候选的主元素。依…

YOLO目标检测——汽车头部尾部检测数据集【含对应voc、coco和yolo三种格式标签】

实际项目应用&#xff1a;用于训练自动驾驶系统中的车辆感知模块&#xff0c;以实现对周围车辆头部和尾部的准确检测和识别数据集说明&#xff1a;汽车头部尾部检测数据集&#xff0c;真实场景的高质量图片数据&#xff0c;数据场景丰富标签说明&#xff1a;使用lableimg标注软…

【JMeter】后置处理器的分类以及场景介绍

1.常用后置处理器的分类 Json提取器 针对响应体的返回结果是json格式的会自动生成新的变量名为【提取器中变量名_MatchNr】,取到的个数由jsonpath expression取到的个数决定 可以当作普通变量调用,调用语法:${提取器中变量名_MatchNr}正则表达式提取器 返回结果是任何数据格…

一款好用的PDF转翻页电子书网站

​你是否曾经遇到过PDF文件无法翻页或者阅读不便的问题&#xff1f;今天给大家推荐一款好用的PDF转翻页电子书网站&#xff0c;让你轻松阅读PDF文件&#xff0c;不再烦恼翻页问题&#xff01; 一、网站介绍 这款FLBOOK在线制作电子杂志网站支持多种电子文件格式转换&#xff0…

JWT简介 JWT结构 JWT示例 前端添加JWT令牌功能 后端程序

目录 1. JWT简述 1.1 什么是JWT 1.2 为什么使用JWT 1.3 JWT结构 1.4 验证过程 2. JWT示例 2.1 后台程序 2.2 前台加入jwt令牌功能 1. JWT简述 1.1 什么是JWT Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准&#xff08;(RFC 7…

〔001〕虚幻 UE5 安装教程

✨ 目录 🎈 下载启动程序🎈 注册个人账户🎈 选择引擎版本🎈 选择安装选项🎈 虚幻商城的使用🎈 每月免费插件🎈 安装插件🎈 下载启动程序 下载地址:https://www.unrealengine.com/zh-CN/download点击上面地址,下载 UE5 启动程序并安装🎈 注册个人账户 打开商…

Linux多虚拟主机和配置限制访问与日志

目录 一、多虚拟主机 1.配置单网卡多个ip 2.给每个主机站点设置主页 3.测试访问 二、限制访问 1.限制所有 2.放行192.168.0.0/24网段访问 三、日志与状态页 1.定义访客日志 2.状态页配置 一、多虚拟主机 1.配置单网卡多个ip ip address add 192.168.0.231/24 dev e…

案例研究|腾讯音乐娱乐集团与JumpServer共探安全运维审计解决方案

近年来&#xff0c;得益于人民消费水平的提升以及版权意识的加强&#xff0c;用户付费意愿和在线用户数量持续增长&#xff0c;中国在线音乐市场呈现出稳定增长的发展态势。随着腾讯音乐于2018年12月上市&#xff0c;进一步推动了中国在线音乐市场的发展。 腾讯音乐娱乐集团&a…

rust入门基础案例:猜数字游戏

案例出处是《Rust权威指南》&#xff0c;书中有更加详细的解释。从这个例子中&#xff0c;我们可以了解到 rust 的两个操作&#xff1a; 如何从控制台读取用户输入rust 如何生成随机数 代码格式化 编译器可在保存时对代码做格式化处理&#xff0c;底层调用 rustfmt 来实现&a…

Kubernetes Dashboard 用户名密码方式登录

Author&#xff1a;rab 前言 为了 K8s 集群安全&#xff0c;默认情况下 Dashboard 以 Token 的形式登录的&#xff0c;那如果我们想以用户名/密码的方式登录该怎么操作呢&#xff1f;其实只需要我们创建用户并进行 ClusterRoleBinding 绑定即可&#xff0c;接下来是具体的操作…

MCU常见通信总线串讲(二)—— RS232和RS485

&#x1f64c;秋名山码民的主页 &#x1f602;oi退役选手&#xff0c;Java、大数据、单片机、IoT均有所涉猎&#xff0c;热爱技术&#xff0c;技术无罪 &#x1f389;欢迎关注&#x1f50e;点赞&#x1f44d;收藏⭐️留言&#x1f4dd; 获取源码&#xff0c;添加WX 目录 前言一…

低代码工具的常见用例与受众市场

目录 一、低代码工具的常见用例是什么&#xff1f; 1.业务流程管理&#xff08;BPM&#xff09; 2.自定义应用程序开发 3.数据管理和分析 4.移动应用程序开发 二、低代码受众和市场 1.制造商 2.个人开发者/自由职业者 3.代理商 4.小型企业和初创企业 5.中型企业 6.营销团队 7.软…

服务号怎么改成订阅号

服务号和订阅号有什么区别&#xff1f;服务号转为订阅号有哪些作用&#xff1f;在推送频率上来看&#xff0c;服务号每月能推送四条消息&#xff0c;而订阅号可以每天&#xff08;24小时&#xff09;推送一条消息。如果企业开通公众号的目的是提供服务&#xff0c;例如售前资讯…

GD32单片机远程升级下载,手机在线升级下载程序,GD32在线固件下载升级,手机下载程序固件方法

GD32、STM32单片机&#xff0c;是我们最常见的一种MCU。通常我们在使用STM32单片机都会遇到程序在线升级下载的问题。 GD32/STM32单片机的在线下载通常需要以下几种方式完成&#xff1a; 1、使用ST/GD提供的串口下载工具&#xff0c;本地完成固件的升级下载。 2、自行完成系统B…