当前位置: 首页 > news >正文

Android Jetpack 系列(四)DataStore 全面解析与实践

1. 简介

在前面的文章《Android SharedPreferences 全面介绍》中,我们全面分析了 SharedPreferences的使用方法和源码原理。它虽然简单强大,但在应对带有并发、系统扩展性需求的场景下,显得力不从心:

  • 并发读写时数据不稳定
  • apply() 是异步写入,但不能确保按顺序写入
  • 不支持类型安全,只支持基础数据类型
  • 不支持响应式读取

为了解决这些问题,Google 在 Jetpack 中推出了新一代本地数据存储方案:Jetpack DataStore。

DataStore 全部基于 Kotlin程协和 Flow 响应模型,可提供两种不同的实现方式:用于存储键值对的 Preferences DataStore 和 用于存储输入对象的 Proto DataStore。数据可支持采用异步、一致和事务的方式进行存储,大大提升了安全性、扩展性和符合 MVVM 设计的能力,是现代 Android 架构推荐使用的本地数据存储方式之一。

2. 了解 DataStore

功能

SharedPreferences

PreferencesDataStore

ProtoDataStore

异步 API

✅(仅用于通过监听器读取更改的值)

✅(通过 Flow)

✅(通过 Flow)

同步 API

✅(但调用界面线程并不安全)

可安全调用界面线程

✅(系统会将工作转移到 Dispatchers.IO
后台)

✅(系统会将工作转移到 Dispatchers.IO
后台)

可以报告错误

避免运行时异常

具有高度一致性保证的事务性 API

处理数据迁移

✅(通过 SharedPreferences)

✅(通过 SharedPreferences)

Preferences 与 Proto DataStore 比较

虽然 Preferences 和 Proto DataStore 都可保存数据,但其操作方式不同:

  • PreferenceDatastore  与 SharedPreferences 一样,是基于键值来访问数据,无需预先定义模式。
  • Proto DataStore 使用protocol-buffers定义模式。使用protocol-buffers支持存留强类型数据。 与 XML 及其他类似的数据格式相比,这种数据更快、更小、更简单且更明确。

Room 与 Datastore 比较

如果需要部分更新、引用完整性或大型/复杂数据集,则应考虑使用 Room 而不是 DataStore。DataStore 是小型或简单数据集的理想选择,不支持部分更新或引用完整性。

3. Preferences DataStore 键值对存储使用指南

3.1 添加项目依赖

dependencies {implementation "androidx.datastore:datastore-preferences:1.1.7"
}

3.2. 获取 DataStore 实例

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

注意:dataStore 应该是一个全局唯一实例,建议绑定在 Application 或 Context 扩展上。

3.3. 读取数据

suspend fun getFirstLaunch(context: Context): Boolean {val firstLaunch: Boolean = context.dataStore.data.first()[booleanPreferencesKey("first_launch")] ?: truereturn firstLaunch
}fun getFirstLaunchFlow(context: Context): Flow<Boolean> {val firstLaunchFlow: Flow<Boolean> = context.dataStore.data.map { it[booleanPreferencesKey("first_launch")] ?: true }return firstLaunchFlow
}suspend fun getUserName(context: Context): String {val userName: String = context.dataStore.data.first()[stringPreferencesKey("username")] ?: ""return userName
}fun getUserNameFlow(context: Context): Flow<String> {val userNameFlow: Flow<String> = context.dataStore.data.map { it[stringPreferencesKey("username")] ?: "" }return userNameFlow
}suspend fun getAge(context: Context): Int {val age: Int = context.dataStore.data.first()[intPreferencesKey("age")] ?: 0return age
}fun getAgeFlow(context: Context): Flow<Int> {val ageFlow: Flow<Int> = context.dataStore.data.map { it[intPreferencesKey("age")] ?: 0 }return ageFlow
}

说明:

读取数据的返回结果可以是标准类型也可以是Flow类型,如果是标准类型,必须要运行在suspend 作用域,如果是Flow类型,它能使其具备响应式能力,支持自动更新监听,可结合 ViewModel + StateFlow 使用。

3.4. 写入数据

suspend fun saveFirstLaunch(context: Context, firstLaunch: Boolean) {context.dataStore.edit { prefs -> prefs[booleanPreferencesKey("first_launch")] = firstLaunch }
}suspend fun saveUsername(context: Context, userName: String) {context.dataStore.edit { prefs -> prefs[stringPreferencesKey("username")] = userName }
}suspend fun saveAge(context: Context, age: Int) {context.dataStore.edit { prefs -> prefs[intPreferencesKey("age")] = age }
}

说明:

1. 所有写入都是原子操作

2. 写入操作也必须要去行在suspend 作用域。

3.5 监听数据变化

fun observeUserName(context: Context): Flow<String> {return context.dataStore.data.map { preferences ->preferences[stringPreferencesKey("username")] ?: ""}.distinctUntilChanged()
}lifecycleScope.launch {observeUserName(context).collect { userName ->Log.d("TAG", "userName change: $userName")}
}

1. 使用 data.map { } 搭配 distinctUntilChanged() 可以订阅特定键

2. 通过Flow.collect监听值变化

如果还有多个键需要监听,也可以用多个 map 和 distinctUntilChanged() 创建多个 Flow,各自独立监听不同的键。

data class UserInfo(val username: String,val age: Int
)fun observeUserInfo(context: Context): Flow<UserInfo> {val usernameFlow = context.dataStore.data.map { it[stringPreferencesKey("username")] ?: "" }.distinctUntilChanged()val ageFlow = context.dataStore.data.map { it[intPreferencesKey("age")] ?: 0 }.distinctUntilChanged()return combine(usernameFlow, ageFlow) { username, age ->UserInfo(username, age)}
}lifecycleScope.launch {observeUserInfo(context).collect { settings ->settings.usernamesettings.age}
}

3.6 其他常用操作

// 清空全部
suspend fun clearAll(context: Context) {context.dataStore.edit { it.clear() }
}// 删除指定键
suspend fun <T> remove(context: Context, key: Preferences.Key<T>) {context.dataStore.edit { it.remove(key) }
}// 是否存在
suspend fun contains(context: Context, key: Preferences.Key<String>): Boolean {return context.dataStore.data.first().contains(key)
}
fun containsFlow(context: Context, key: Preferences.Key<String>): Flow<Boolean> {return context.dataStore.data.map { it.contains(key) }
}

4. Proto DataStore 结构化存储方案使用指南

4.1. 添加项目依赖

dependencies {implementation "androidx.datastore:datastore:1.1.7"implementation " com.google.protobuf:protobuf-java:3.20.1"
}

4.2. 配置 proto 并生成类

DataStore进行对象的存储需要依赖 Protocol Buffers 先使对象序列化,在读取时再进行反序列化,关于 Protocol Buffers 的详细介绍可参考之前的文章《Android序列化(五) 之 Protocol Buffers》。

配置proto

syntax = "proto3";option java_package = "com.zyx.datastore.demo.proto";
option java_outer_classname = "UserProto";message User {string username = 1;int32 age = 2;bool is_premium = 3;
}

4.3. 创建 Proto DataStore 实例

定义 Serializer

object UserSerializer: Serializer<UserProto.User> {override val defaultValue: UserProto.Userget() = UserProto.User.getDefaultInstance()override suspend fun readFrom(input: InputStream): UserProto.User {try {return UserProto.User.parseFrom(input)} catch (e: InvalidProtocolBufferException) {throw CorruptionException("Cannot read proto", e)}}override suspend fun writeTo(t: UserProto.User, output: OutputStream) {t.writeTo(output)}
}

获取 DataStore 实例

val Context.userDataStore by dataStore(fileName = "user ", serializer = UserSerializer)

4.4. 读取数据

suspend fun getUser(context: Context): User {return context.userDataStore.data.first()
}fun getUserFlow(context: Context): Flow<User> {return context.userDataStore.data
}

4.5. 写入数据

suspend fun updateUsername(context: Context, newName: String) {context.userDataStore.updateData { current ->current.toBuilder().setUsername(newName).build()}
}suspend fun updateAge(context: Context, age: Int) {context.userDataStore.updateData { current ->current.toBuilder().setAge(age).build()}
}suspend fun updateUsernameAndAge(context: Context, newName: String, age: Int) {context.userDataStore.updateData { current ->current.toBuilder().setUsername(newName).setAge(age).build()}
}

5. 源码原理简析

5.1. 创建对象

以 Preferences 为例,前面我们在获取 DataStore 实例时,使用了以下代码:

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

这里的 preferencesDataStore 方法就是一个包级函数,代码如下:

PreferenceDataStoreDelegate.android.kt

@Suppress("MissingJvmstatic")
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)
}/*** Delegate class to manage Preferences DataStore as a singleton.*/
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>? = null/*** Gets the instance of the DataStore.** @param thisRef must be an instance of [Context]* @param property not used*/override 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!!}}
}

方法返回 ReadOnlyProperty 对象,可见在getValue方法里面调用了 PreferenceDataStoreFactory.create 方法去创建DataStore对象,其中 applicationContext.preferencesDataStoreFile(name) 就是指定文件存储位置,即:/data/data/【包名】/files/datastore/【自定义名称】.preferences_pb。

PreferenceDataStoreFactory.jvmAndroid.kt

public actual object PreferenceDataStoreFactory {@JvmOverloadspublic fun create(corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,migrations: List<DataMigration<Preferences>> = listOf(),scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),produceFile: () -> File): DataStore<Preferences> {val delegate =create(storage =FileStorage(PreferencesFileSerializer) {val file = produceFile()check(file.extension == PreferencesSerializer.fileExtension) {"File extension for file: $file does not match required extension for" +" Preferences file: ${PreferencesSerializer.fileExtension}"}file.absoluteFile},corruptionHandler = corruptionHandler,migrations = migrations,scope = scope)return PreferenceDataStore(delegate)}@JvmOverloadspublic actual fun create(storage: Storage<Preferences>,corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,migrations: List<DataMigration<Preferences>>,scope: CoroutineScope,): DataStore<Preferences> {return PreferenceDataStore(DataStoreFactory.create(storage = storage,corruptionHandler = corruptionHandler,migrations = migrations,scope = scope))}
……
}

create方法会去创建了一个 DataStore<Preferences> 对象,通过委托的方式返回一个 PreferenceDataStore 对象,而实际上对象是通过 DataStoreFactory.create 方法进行创建

DataStoreFactory.jvm.kt

public actual object DataStoreFactory {
……@JvmOverloadspublic actual fun <T> create(storage: Storage<T>,corruptionHandler: ReplaceFileCorruptionHandler<T>?,migrations: List<DataMigration<T>>,scope: CoroutineScope,): DataStore<T> =DataStoreImpl(storage = storage,corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(),initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)),scope = scope)
}

到此能发现其最终实现的类就是 DataStoreImpl

看回 PreferenceDataStore 对象,updateData 方法的 transform 参数,便是外部进行写入数据时调用 edit 方法所传入的 transform。

PreferenceDataStoreFactory.kt

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}}
}

Preferences.kt

public suspend fun DataStore<Preferences>.edit(transform: suspend (MutablePreferences) -> Unit
): Preferences {return this.updateData {it.toMutablePreferences().apply { transform(this) }}
}

5.2. 写入数据

紧接上面源码分析,updateData 方法除了调用外部实现的 transform(it) 外还会有自己内部逻辑,继续来看 DataStoreImpl 类的实现:

DataStoreImpl.kt

override suspend fun updateData(transform: suspend (t: T) -> T): T {val parentContextElement = coroutineContext[UpdatingDataContextElement.Companion.Key]parentContextElement?.checkNotUpdating(this)val childContextElement = UpdatingDataContextElement(parent = parentContextElement,instance = this)return withContext(childContextElement) {val ack = CompletableDeferred<T>()val currentDownStreamFlowState = inMemoryCache.currentStateval updateMsg =Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)writeActor.offer(updateMsg)ack.await()}
}
private val writeActor = SimpleActor<Message.Update<T>>(scope = scope,onComplete = {it?.let {inMemoryCache.tryUpdate(Final(it))}if (storageConnectionDelegate.isInitialized()) {storageConnection.close()}},onUndeliveredElement = { msg, ex ->msg.ack.completeExceptionally(ex ?: CancellationException("DataStore scope was cancelled before updateData could complete"))}
) { msg ->handleUpdate(msg)
}

这段代码中,重点关注 writeActor,它内部维护着一个消息队列,当新消息来到时会继续调用 handleUpdate 方法:

private suspend fun handleUpdate(update: Message.Update<T>) {update.ack.completeWith(runCatching {val result: Twhen (val currentState = inMemoryCache.currentState) {is Data -> { result = transformAndWrite(update.transform, update.callerContext)}is ReadException, is UnInitialized -> {if (currentState === update.lastState) {readAndInitOrPropagateAndThrowFailure()result = transformAndWrite(update.transform, update.callerContext)} else {throw (currentState as ReadException).readException}}is Final -> throw currentState.finalException}result})
}
private suspend fun transformAndWrite(transform: suspend (t: T) -> T,callerContext: CoroutineContext
): T = coordinator.lock {val curData = readDataOrHandleCorruption(hasWriteFileLock = true)val newData = withContext(callerContext) { transform(curData.value) }// Check that curData has not changed...curData.checkHashCode()if (curData.value != newData) {writeData(newData, updateCache = true)}newData
}

handleUpdate 方法再调用到 transformAndWrite 方法,该方法主要是给要进行写入的文件加锁,然后再调用 writeData 方法进行数据写入:

internal suspend fun writeData(newData: T, updateCache: Boolean): Int {var newVersion = 0storageConnection.writeScope {newVersion = coordinator.incrementAndGetVersion()writeData(newData)if (updateCache) {inMemoryCache.tryUpdate(Data(newData, newData.hashCode(), newVersion))}}return newVersion
}

storageConnection 对象是类 DataStoreImpl 上的变量,定义如下:

private val storageConnectionDelegate = lazy {storage.createConnection()
}
internal val storageConnection by storageConnectionDelegate

它的实现便是通过 storage.createConnection() 获取,而 storage 就是上面在创建对象调用 PreferenceDataStoreFactory.create 方法内传入的FileStorage(PreferencesFileSerializer),所以 writeScope 内的 writeData接口方法真正实现在 FileStorage.kt 中:

FileStorage.kt

internal class FileWriteScope<T>(file: File, serializer: Serializer<T>) :FileReadScope<T>(file, serializer), WriteScope<T> {override suspend fun writeData(value: T) {checkNotClosed()val fos = FileOutputStream(file)fos.use { stream ->serializer.writeTo(value, UncloseableOutputStream(stream))stream.fd.sync()}}
}

这里获取文件输出流,并通过Serializer 的 writeTo 方法进行写入数据。也从上面创建对象调用PreferenceDataStoreFactory.create 方法内传入的的FileStorage(PreferencesFileSerializer)可知,该方法的实现在 PreferencesFileSerializer 中:

PreferencesFileSerializer.jvmAndroid.kt

@Suppress("InvalidNullabilityOverride") // Remove after b/232460179 is fixed
@Throws(IOException::class, CorruptionException::class)
override suspend fun writeTo(t: Preferences, output: OutputStream) {val preferences = t.asMap()val protoBuilder = PreferenceMap.newBuilder()for ((key, value) in preferences) {protoBuilder.putPreferences(key.name, getValueProto(value))}protoBuilder.build().writeTo(output)
}

方法内是通过使用 PreferenceMap 来实现数据的写入,PreferenceMap 的特点是:

1. Map 的 key 是泛型的 Preferences.Key<T>,value 是 Any?,允许不同类型的值共存;

2. PreferenceMap 的内部序列化也是使用 Protocol Buffer 实现。

5.3. 读取数据

获取数据是通过 data 返回一个 flow 对象,每当调用 .data.first() 或 .data.collect {} 时,就会触发这个 Flow 的执行流程。

DataStoreImpl.kt

override val data: Flow<T> = flow {val startState = readState(requireLock = false)when (startState) {is Data<T> -> emit(startState.value)is UnInitialized -> error(BUG_MESSAGE)is ReadException<T> -> throw startState.readExceptionis Final -> return@flow}emitAll(inMemoryCache.flow.onStart { incrementCollector() }.takeWhile {it !is Final}.dropWhile { it is Data && it.version <= startState.version }.map {when (it) {is ReadException<T> -> throw it.readExceptionis Data<T> -> it.valueis Final<T>,is UnInitialized -> error(BUG_MESSAGE)}}.onCompletion { decrementCollector() })
}

1. 用 readState() 从缓存或文件中读取当前状态,结果是 State<T> 的一个子类;

2. 判断读取结果类型并处理,正常读取到的数据,直接发射(emit)出去;

3. emitAll是监听数据变化并持续发射新值,从内存缓存 (inMemoryCache) 中持续观察数据变化。

6. 多进程支持

DataStore 默认是单进程安全的。在早期版本(1.0.x)中,它并未设计为支持多进程并发访问,因此在多进程环境中同时读写可能引发数据不一致或竞争问题。

从 androidx.datastore:datastore-core:1.1.0-alpha01 起,官方引入了对多进程的支持,提供了 MultiProcessDataStoreFactory 用于创建具备多进程访问能力的 DataStore 实例。

与单进程下提供的 preferencesDataStore 和 dataStore 这类简洁扩展方法不同,官方并未为多进程场景提供类似封装。不过我们可以参考其实现方式,自定义多进程版本的封装方法。

6.1. Preferences DataStore

可以参照 preferencesDataStore 的实现思路,封装一个 preferencesDataStoreMulti 方法:

fun preferencesDataStoreMulti(name: String): ReadOnlyProperty<Context, DataStore<Preferences>> {return PreferenceDataStoreMultiDelegate(name)
}internal class PreferenceDataStoreMultiDelegate internal constructor(private val fileName: String) :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.applicationContext// 由于 PreferencesFileSerializer 是 internal object,只能通过反射获取val clazz = Class.forName("androidx.datastore.preferences.core.PreferencesFileSerializer")val serializer = clazz.getField("INSTANCE").get(null) as Serializer<Preferences>INSTANCE = MultiProcessDataStoreFactory.create(serializer = serializer,produceFile = {applicationContext.dataStoreFile(fileName)})}INSTANCE!!}}
}

 然后即可像使用单进程的 preferencesDataStore 一样,定义全局多进程安全的 DataStore 实例:

val Context.dataStoreMulti: DataStore<Preferences> by preferencesDataStoreMulti(name = "multi_settings")

6.2. Proto DataStore

Proto DataStore 的多进程封装方式类似,也可参考单进程的 dataStore 扩展方法封装如下

fun <T> dataStoreMulti(fileName: String, serializer: Serializer<T>): ReadOnlyProperty<Context, DataStore<T>> {return DataStoreMultiDelegate(fileName, serializer)
}internal class DataStoreMultiDelegate<T> internal constructor(private val fileName: String, private val serializer: Serializer<T>) : ReadOnlyProperty<Context, DataStore<T>> {private val lock = Any()@GuardedBy("lock")@Volatileprivate var INSTANCE: DataStore<T>? = nulloverride fun getValue(thisRef: Context, property: KProperty<*>): DataStore<T> {return INSTANCE ?: synchronized(lock) {if (INSTANCE == null) {val applicationContext = thisRef.applicationContextINSTANCE = MultiProcessDataStoreFactory.create(serializer = serializer,produceFile = {applicationContext.dataStoreFile(fileName)})}INSTANCE!!}}
}

然后即可像使用单进程的 DataStore一样,定义全局多进程安全的 DataStore 实例:

val Context.userDataStoreMulti by dataStoreMulti(fileName = "multi_user", serializer = UserSerializer)

7. 总结

DataStore 是 Google 面向现代 Android 架构推出的响应式本地数据存储方案,它通过协程、Flow 和类型安全特性,为开发者提供更强的能力和更清晰的数据管理模型。

但这并不意味着 SharedPreferences 立即过时。两者各有适用场景:

  • 如果你正在开发一个新的 MVVM 架构项目,推荐使用 DataStore,它能很好地与 ViewModel、StateFlow 等 Jetpack 组件协同工作,且具有良好的并发与响应式特性。

  • 如果你的项目已经大量使用 SharedPreferences,且数据量不大、读写简单、依赖不想扩大,那么保留 SharedPreferences 依然是可行的选择。

  • 如果需要结构化、跨模块的缓存或配置中心系统,则优先考虑 Proto DataStore。

最佳实践建议:

  • Preferences DataStore 可用于替代 SharedPreferences 的简单键值对配置;

  • Proto DataStore 更适合管理复杂结构的配置和缓存;

  • SharedPreferences 可作为过渡或轻量场景的方案继续使用。

总之,在 Jetpack 架构体系下,DataStore 的引入是一次本地数据持久化的升级,它不只是替代品,更是现代响应式架构的一部分。

http://www.dtcms.com/a/285128.html

相关文章:

  • RSTP:快速收敛的生成树技术
  • 深入解析SVM:从对偶问题求解到核函数理论
  • [3-03-01].第61节:开发应用 - Seata中的SAGA模式
  • 防止电脑息屏 html
  • Bell不等式赋能机器学习:微算法科技MLGO一种基于量子纠缠的监督量子分类器训练算法技术
  • Java 8 jdk1.8下载及安装教程和环境变量配置
  • 电子电路中的电压符号命名约定
  • 【前端如何利用 localStorage 存储 Token 及跨域问题解决方案】
  • Python网络爬虫之requests库
  • ISL8121IRZ-T 瑞萨电子Renesas高效双路同步降压控制器 【5G基站、AI服务器】专用
  • LIN通信驱动代码开发注意事项
  • 多重共线性Multicollinearity
  • 复合机器人在生物制药实验室上下料搬运案例
  • LeetCode热题100【第二天】
  • 91套商业策划创业融资计划书PPT模版
  • AppTrace:重新定义免填邀请码,解锁用户裂变新高度
  • 50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | PasswordGenerator(密码生成器)
  • 三、了解OpenCV的数据类型
  • 高效去除字符串末尾重复单元的 KMP 前缀函数优化算法实现
  • VR 远程系统的沉浸式协作体验​
  • SpringBoot 使用MyBatisPlus
  • 在windows平台上基于OpenHarmony sdk编译三方库并暴露给ArkTS使用(详细)
  • VSCODE常规设置
  • No catalog entry ‘md5‘ was found for catalog ‘default‘. 的简单解决方法
  • 学习软件测试的第十八天
  • 一款基于PHP开发的不良事件上报系统源码,适用于医院安全管理。系统提供10类事件类别、50余种表单,支持在线填报、匿名上报及紧急报告。
  • 前端防复制实战指南:5 种主流方案效果对比与实现
  • Ubuntu20.04上安装Anaconda
  • 磁盘分区(D盘分给C盘)
  • 【Triton 教程】triton_language.zeros_like