Android Jetpack系列之DataStore
*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
文章目录
- 一 、DataStore介绍
- 二、SharedPreferences缺点
- 三、DataStore使用
- 3.1 Preferences DataStore
- 添加依赖项
- 构建Preferences DataStore
- 存数据
- 取数据
- SP迁移至Preferences DataStore
- 3.2 Proto DataStore
- 添加依赖项
- 构建Proto DataStore
- 存数据
- 取数据
- SP迁移至Proto DataStore
- 四、总结
- 五、参考
一 、DataStore介绍
Jetpack DataStore
是一种改进的新数据存储解决方案,允许使用协议缓冲区存储键值对或类型化对象。DataStore
以异步、一致的事务方式存储数据,克服了 SharedPreferences(以下统称为SP)的一些缺点。DataStore
基于Kotlin
协程和Flow
实现,并且可以对SP
数据进行迁移,旨在取代SP
。
DataStore
提供了两种不同的实现:Preferences DataStore
与Proto DataStore
,其中Preferences DataStore
用于存储键值对;Proto DataStore
用于存储类型化对象,后面会分别给出对应的使用例子。
二、SharedPreferences缺点
DataStore
出现之前,我们用的最多的存储方式毫无疑问是SP
,其使用方式简单、易用,广受好评。然而google
对SP
的定义为轻量级存储,如果存储的数据少,使用起来没有任何问题,当需要存储数据比较多时,SP
可能会导致以下问题:
- SP第一次加载数据时需要全量加载,当数据量大时可能会阻塞UI线程造成卡顿
- SP读写文件不是类型安全的,且没有发出错误信号的机制,缺少事务性API
- commit() / apply()操作可能会造成ANR问题:
commit()
是同步提交,会在UI
主线程中直接执行IO
操作,当写入操作耗时比较长时就会导致UI
线程被阻塞,进而产生ANR
;apply()
虽然是异步提交,但异步写入磁盘时,如果执行了Activity / Service
中的onStop()
方法,那么一样会同步等待SP
写入完毕,等待时间过长时也会引起ANR
问题。针对apply()
我们展开来看一下:
SharedPreferencesImpl#EditorImpl.java中最终执行了apply()
函数:
public final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);public void apply() {final MemoryCommitResult mcr = commitToMemory();final Runnable awaitCommit = new Runnable() {public void run() {try {mcr.writtenToDiskLatch.await();} catch (InterruptedException ignored) {}}};//8.0之前QueuedWork.add(awaitCommit);//8.0之后QueuedWork.addFinisher(awaitCommit);//异步执行磁盘写入操作SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);//......其他......
}
构造一个名为awaitCommit
的Runnable
任务并将其加入到QueuedWork
中,该任务内部直接调用了CountDownLatch.await()
方法,即直接在UI
线程执行等待操作,那么需要看QueuedWork
中何时执行这个任务。
QueuedWork
类在Android8.0以上和8.0以下
的版本实现方式有区别:
8.0之前QueuedWork.java:
public class QueuedWork {private static final ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers =new ConcurrentLinkedQueue<Runnable>();public static void add(Runnable finisher) {sPendingWorkFinishers.add(finisher);}public static void waitToFinish() {Runnable toFinish;// 从队列中取出任务:如果任务为空,则跳出循环,UI线程可以继续往下执行;//反之任务不为空,取出任务并执行,实际执行的CountDownLatch.await(),即UI线程会阻塞等待while ((toFinish = sPendingWorkFinishers.poll()) != null) {toFinish.run();}}//......其他......
}
8.0之后QueuedWork.java:
public class QueuedWork {private static final LinkedList<Runnable> sFinishers = new LinkedList<>();public static void waitToFinish() {Handler handler = getHandler();StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();try {//8.0之后优化,会主动尝试执行写磁盘任务processPendingWork();} finally {StrictMode.setThreadPolicy(oldPolicy);}try {while (true) {Runnable finisher;synchronized (sLock) {//从队列中取出任务finisher = sFinishers.poll();}//如果任务为空,则跳出循环,UI线程可以继续往下执行if (finisher == null) {break;}//任务不为空,执行CountDownLatch.await(),即UI线程会阻塞等待finisher.run();}} finally {sCanDelay = true;}}}
可以看到不管8.0
之前还是之后,waitToFinish()
都会尝试从Runnable
任务队列中取任务,如果有的话直接取出并执行,直接看哪里调用了waitToFinish()
:
ActivityThread.java
private void handleStopActivity(IBinder token, boolean show, int configChanges, int seq) {//......其他......QueuedWork.waitToFinish();
}private void handleStopService(IBinder token) {//......其他......QueuedWork.waitToFinish();
}
省略了一些代码细节,可以看到在ActivityThread
中handleStopActivity
、handleStopService
方法中都会调用waitToFinish()
方法,即在Activity
的onStop()
中、Service
的onStop()
中都会先同步等待写入任务完成才会继续执行。
所以apply()
虽然是异步写入磁盘,但是如果此时执行到Activity/Service
的onStop()
,依然可能会阻塞UI
线程导致ANR
。
画外音:SP
使用过程中导致的ANR问题,可以通过一些Hook
手段进行优化,如字节发布的 今日头条 ANR 优化实践系列 - 告别 SharedPreference 等待。我司项目里使用的SP
也是按此优化,优化后效果还是比较显著的,所以目前项目也还没有对SP
进行迁移(如迁移到MMKV
或DataStore
),但并不影响我们学习新的存储姿势。
三、DataStore使用
DataStore优势:
DataStore
基于事务方式处理数据更新。DataStore
基于Kotlin Flow
存取数据,默认在Dispatchers.IO
里异步操作,避免阻塞UI
线程,且在读取数据时能对发生的Exception
进行处理。- 不提供
apply()、commit()
存留数据的方法。 - 支持
SP
一次性自动迁移至DataStore
中。
3.1 Preferences DataStore
添加依赖项
implementation 'androidx.datastore:datastore-preferences:1.0.0'
构建Preferences DataStore
val Context.bookDataStorePf: DataStore<Preferences> by preferencesDataStore(// 文件名称name = "pf_datastore")
通过上面的代码,我们就成功创建了Preferences DataStore
,其中preferencesDataStore()
是一个顶层函数,包含以下几个参数:
- name:创建
Preferences DataStore
的文件名称。 - corruptionHandler:如果
DataStore
在试图读取数据时,数据无法反序列化,会抛出androidx.datastore.core.CorruptionException
,此时会执行corruptionHandler
。 - produceMigrations:
SP
产生迁移到Preferences DataStore
。ApplicationContext
作为参数传递给这些回调,迁移在对数据进行任何访问之前运行。 - scope:协程作用域,默认
IO
操作在Dispatchers.IO
线程中执行。
上述代码执行后,会在/data/data/项目包名/files/
下创建名为pf_datastore
的文件如下:
可以看到后缀名并不是xml
,而是.preferences_pb
。这里需要注意一点:不能将上面的初始化代码写到Activity
里面去,否则重复进入Actvity
并使用Preferences DataStore
时,会尝试去创建一个同名的.preferences_pb
文件,因为之前已经创建过一次,当检测到尝试创建同名文件时,会直接抛异常:
java.lang.IllegalStateException: There are multiple DataStores active for the same file:xxx. You should either maintain your DataStore as a singleton or confirm that there is no two DataStore's active on the same file (by confirming that the scope is cancelled).
报错类在androidx.datastore:datastore-core:1.0.0
的androidx/datastore/core/SingleProcessDataStore
下:
internal val activeFiles = mutableSetOf<String>()
file.absolutePath.let {synchronized(activeFilesLock) {check(!activeFiles.contains(it)) {"There are multiple DataStores active for the same file: $file. You should " +"either maintain your DataStore as a singleton or confirm that there is " +"no two DataStore's active on the same file (by confirming that the scope" +" is cancelled)."}activeFiles.add(it)}}
其中file
是通过File(applicationContext.filesDir, "datastore/$fileName")
生成的文件,即Preferences DataStore
最终要在磁盘中操作的文件地址,activeFiles
是在内存中保存生成的文件路径的,如果判断到activeFiles
里已经有该文件,直接抛异常,即不允许重复创建。
存数据
首先声明一个实体类BookModel:
data class BookModel(var name: String = "",var price: Float = 0f,var type: Type = Type.ENGLISH
)enum class Type {MATH,CHINESE,ENGLISH
}
BookRepo.kt
中执行存储操作:
const val KEY_BOOK_NAME = "key_book_name"
const val KEY_BOOK_PRICE = "key_book_price"
const val KEY_BOOK_TYPE = "key_book_type"//Preferences.Key<T>类型
object PreferenceKeys {val P_KEY_BOOK_NAME = stringPreferencesKey(KEY_BOOK_NAME)val P_KEY_BOOK_PRICE = floatPreferencesKey(KEY_BOOK_PRICE)val P_KEY_BOOK_TYPE = stringPreferencesKey(KEY_BOOK_TYPE)
}/*** Preferences DataStore存数据*/
suspend fun saveBookPf(book: BookModel) {context.bookDataStorePf.edit { preferences ->preferences[PreferenceKeys.P_KEY_BOOK_NAME] = book.namepreferences[PreferenceKeys.P_KEY_BOOK_PRICE] = book.pricepreferences[PreferenceKeys.P_KEY_BOOK_TYPE] = book.type.name}
}
Activity
中:
lifecycleScope.launch {val book = BookModel(name = "Hello Preferences DataStore",price = (1..10).random().toFloat(), //这里价格每次点击都会变化,为了展示UI层能随时监听数据变化type = Type.MATH )mBookRepo.savePfData(book)
}
通过 bookDataStorePf.edit(transform: suspend (MutablePreferences) -> Unit)
挂起函数进行存储,该函数接受 transform
块,能够以事务方式更新DataStore
中的状态。
取数据
/*** Preferences DataStore取数据 取数据时可以对Flow数据进行一系列处理*/
val bookPfFlow: Flow<BookModel> = context.bookDataStorePf.data.catch { exception ->// dataStore.data throws an IOException when an error is encountered when reading dataif (exception is IOException) {emit(emptyPreferences())} else {throw exception}
}.map { preferences ->//对应的Key是 Preferences.Key<T>val bookName = preferences[PreferenceKeys.P_KEY_BOOK_NAME] ?: ""val bookPrice = preferences[PreferenceKeys.P_KEY_BOOK_PRICE] ?: 0fval bookType = Type.valueOf(preferences[PreferenceKeys.P_KEY_BOOK_TYPE] ?: Type.MATH.name)return@map BookModel(bookName, bookPrice, bookType)
}
Activity中:
lifecycleScope.launch {mBookViewModel.bookPfFlow.collect {mTvContentPf.text = it.toString()}
}
通过bookDataStorePf.data
返回的是Flow<BookModel>
,那么后续就可以通过Flow
对数据进行一系列处理。从文件读取数据时,如果出现错误,系统会抛出IOExceptions
。可以在 map()
之前使用 catch()
运算符,并且在抛出的异常是 IOException
时发出 emptyPreferences()
。如果出现其他类型的异常,重新抛出该异常。
注意:Preferences DataStore
存取数据时的Key
是Preferences.Key< T>
类型,且其中的T
只能存 Int、Long、Float、Double、Boolean、String、Set< String>
类型,此限制在androidx/datastore/preferences/core/PreferencesSerializer
类参与序列化的getValueProto()
方法中:
private fun getValueProto(value: Any): Value {return when (value) {is Boolean -> Value.newBuilder().setBoolean(value).build()is Float -> Value.newBuilder().setFloat(value).build()is Double -> Value.newBuilder().setDouble(value).build()is Int -> Value.newBuilder().setInteger(value).build()is Long -> Value.newBuilder().setLong(value).build()is String -> Value.newBuilder().setString(value).build()is Set<*> ->@Suppress("UNCHECKED_CAST")Value.newBuilder().setStringSet(StringSet.newBuilder().addAllStrings(value as Set<String>)).build()//如果不是上面的类型,会直接抛异常else -> throw IllegalStateException("PreferencesSerializer does not support type: ${value.javaClass.name}")}}
可以看到最后一个else
逻辑中,如果不是上面的类型,会直接抛异常。因为Key
是Preferences.Key< T>
类型,系统默认帮我们包了一层,位于androidx.datastore.preferences.core.PreferencesKeys.kt
:
public fun intPreferencesKey(name: String): Preferences.Key<Int> = Preferences.Key(name)public fun doublePreferencesKey(name: String): Preferences.Key<Double> = Preferences.Key(name)public fun stringPreferencesKey(name: String): Preferences.Key<String> = Preferences.Key(name)public fun booleanPreferencesKey(name: String): Preferences.Key<Boolean> = Preferences.Key(name)public fun floatPreferencesKey(name: String): Preferences.Key<Float> = Preferences.Key(name)public fun longPreferencesKey(name: String): Preferences.Key<Long> = Preferences.Key(name)public fun stringSetPreferencesKey(name: String): Preferences.Key<Set<String>> =Preferences.Key(name)
因为上述的声明都在顶层函数中,所以可以直接使用。比如我们想声明一个String类型的Preferences.Key< T>
,可以直接如下进行声明:
val P_KEY_NAME: Preferences.Key<String> = stringPreferencesKey("key")
SP迁移至Preferences DataStore
如果想对SP
进行迁移,只需在Preferences DataStore
构建环节添加produceMigrations
参数(该参数含义创建环节已介绍)如下:
//SharedPreference文件名
const val BOOK_PREFERENCES_NAME = "book_preferences"val Context.bookDataStorePf: DataStore<Preferences> by preferencesDataStore(name = "pf_datastore", //DataStore文件名称//将SP迁移到Preference DataStore中produceMigrations = { context ->listOf(SharedPreferencesMigration(context, BOOK_PREFERENCES_NAME))}
)
这样构建完成时,SP
中的内容也会迁移到Preferences DataStore
中了,注意迁移是一次性的,即执行迁移后,SP文件会被删除,如下:
迁移之前(SP
文件已存在):
创建Preferences DataStore并执行迁移后(SP文件已经被删除):
3.2 Proto DataStore
SP
和 Preferences DataStore
的一个缺点是无法定义架构,保证不了存取键时使用了正确的数据类型。Proto DataStore
可利用 Protocol Buffers协议缓冲区 定义架构来解决此问题。
Protobuf协议缓冲区是一种对结构化数据进行序列化的机制。通过使用协议,Proto DataStore
可以知道存储的类型,无需使用键便能提供类型。
添加依赖项
1、添加协议缓冲区插件及 Proto DataStore 依赖项
为了使用Proto DataStore
,让协议缓冲区为我们的架构生成代码,需要在build.gradle
中引入protobuf
插件:
plugins {...id "com.google.protobuf" version "0.8.17"
}android {//.............其他配置..................sourceSets {main {java.srcDirs = ['src/main/java']proto {//指定proto源文件地址srcDir 'src/main/protobuf'include '**/*.protobuf'}}}//proto buffer 协议缓冲区相关配置 用于DataStoreprotobuf {protoc {//protoc版本参见:https://repo1.maven.org/maven2/com/google/protobuf/protoc/artifact = "com.google.protobuf:protoc:3.18.0"}// Generates the java Protobuf-lite code for the Protobufs in this project. See// https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation// for more information.generateProtoTasks {all().each { task ->task.builtins {java {option 'lite'}}}}//修改生成java类的位置 默认是 $buildDir/generated/source/protogeneratedFilesBaseDir = "$projectDir/src/main/generated"}
}dependencies {api 'androidx.datastore:datastore:1.0.0'api "com.google.protobuf:protobuf-javalite:3.18.0"...
}
需要配置或引入的库看上去还挺多,可以考虑将这些配置单独放到一个module
中去。
2、定义和使用protobuf对象
只需对数据结构化的方式进行一次定义,编译器便会生成源代码,轻松写入和读取结构化数据。我们是配置依赖项的sourceSets{}
中声明了proto
源码地址路径在src/main/protobuf
,所有的proto
文件都要在该声明的路径下:
Book.proto文件内容:
//指定protobuf版本,没有指定默认使用proto2,必须在第一行进行指定
syntax = "proto3";//option:可选字段
//java_package:指定proto文件生成的java类所在的包名
option java_package = "org.ninetripods.mq.study";
//java_outer_classname:指定该proto文件生成的java类的名称
option java_outer_classname = "BookProto";enum Type {MATH = 0;CHINESE = 1;ENGLISH = 2;
}message Book {string name = 1; //书名float price = 2; //价格Type type = 3; //类型
}
上述代码编写完后,执行Build -> ReBuild Project
,就会在generatedFilesBaseDir
配置的路径下生成对应Java
代码,如下:
3、创建序列化器
序列化器定义了如何存取我们在 proto 文件中定义的数据类型。如果磁盘上没有数据,序列化器还会定义默认返回值。如下我们创建一个名为BookSerializer
的序列化器:
object BookSerializer : Serializer<BookProto.Book> {override val defaultValue: BookProto.Book = BookProto.Book.getDefaultInstance()override suspend fun readFrom(input: InputStream): BookProto.Book {try {return BookProto.Book.parseFrom(input)} catch (exception: InvalidProtocolBufferException) {throw CorruptionException("Cannot read proto.", exception)}}override suspend fun writeTo(t: BookProto.Book, output: OutputStream) {t.writeTo(output)}
}
其中,BookProto.Book
是通过协议缓冲区生成的代码,如果找不到 BookProto.Book
对象或相关方法,可以清理并Rebuild
项目,以确保协议缓冲区生成对象。
构建Proto DataStore
//构建Proto DataStore
val Context.bookDataStorePt: DataStore<BookProto.Book> by dataStore(fileName = "BookProto.pb",serializer = BookSerializer)
dataStore为顶层函数,可以传入的参数如下:
- fileName: 创建
Proto DataStore
的文件名称。 - serializer:
Serializer序列化器
定义了如何存取格式化数据。 - corruptionHandler:如果
DataStore
在试图读取数据时,数据无法反序列化,抛出androidx.datastore.core.CorruptionException
,则调用corruptionHandler。 - produceMigrations:
SP
迁移到Proto DataStore
时执行。ApplicationContext
作为参数传递给这些回调,迁移在对数据进行任何访问之前运行 - scope:协程作用域,默认
IO
操作在Dispatchers.IO
线程中执行。
上述代码执行后,会在/data/data/项目包名/files/
下创建名为BookProto.pb
的文件如下:
存数据
lifecycleScope.launch {//构建BookProto.Bookval bookInfo = BookProto.Book.getDefaultInstance().toBuilder().setName("Hello Proto DataStore").setPrice(20f).setType(BookProto.Type.ENGLISH).build()bookDataStorePt.updateData { bookInfo }}
Proto DataStore
提供了一个挂起函数DataStore.updateData()
来存数据,当存储完成时,协程也执行完毕。
取数据
/*** Proto DataStore取数据*/val bookProtoFlow: Flow<BookProto.Book> = context.bookDataStorePt.data.catch { exception ->if (exception is IOException) {emit(BookProto.Book.getDefaultInstance())} else {throw exception}}//Activity中
lifecycleScope.launch {mBookViewModel.bookProtoFlow.collect {mTvContentPt.text = it.toString()}
}
Proto DataStore
取数据方式跟Preferences DataStore
一样,不再赘述。
SP迁移至Proto DataStore
//构建Proto DataStore
val Context.bookDataStorePt: DataStore<BookProto.Book> by dataStore(fileName = "BookProto.pb",serializer = BookSerializer,//将SP迁移到Proto DataStore中produceMigrations = { context ->listOf(androidx.datastore.migrations.SharedPreferencesMigration(context,BOOK_PREFERENCES_NAME) { sharedPrefs: SharedPreferencesView, currentData: BookProto.Book ->//从SP中取出数据val bookName: String = sharedPrefs.getString(KEY_BOOK_NAME, "") ?: ""val bookPrice: Float = sharedPrefs.getFloat(KEY_BOOK_PRICE, 0f)val typeStr = sharedPrefs.getString(KEY_BOOK_TYPE, BookProto.Type.MATH.name)val bookType: BookProto.Type =BookProto.Type.valueOf(typeStr ?: BookProto.Type.MATH.name)//将SP中的数据存入Proto DataStore中currentData.toBuilder().setName(bookName).setPrice(bookPrice).setType(bookType).build()})}
)
Proto DataStore
定义了 SharedPreferencesMigration
类。migrate
里指定了下面两个参数:
- SharedPreferencesView: 可以用于从
SharedPreferences
中检索数据 - BookProto.Book:当前数据
同样地在创建时如果传入了produceMigrations
,那么SP
文件会迁移至Proto DataStore
,迁移完后SP
文件被删除。
这里还需要注意一点,Preferences DataStore
、Proto DataStore
在执行迁移时都会用到SharedPreferencesMigration
类,但是这两个地方用到该类对应的包名是不一样的,如Proto DataStore
的包名路径是androidx.datastore.migrations.SharedPreferencesMigration
,当把他们写在一个文件里时,注意其中一个要使用完整路径。
四、总结
直接上官方给出的SP
与DataStore
对比吧:
文章中完整示例代码参见:Jetpack DataStore示例
五、参考
【1】官方:使用 Jetpack DataStore 进行数据存储
【2】官方:使用 Preferences DataStore
Android Jetpack系列之DataStore相关推荐
- Android JetPack系列---Lifecycle
Android JetPack系列-Lifecycle jetpack也出来很长一段时间了,最近比较闲,然后顺便记录一下自己的学习.然后准备打算的是写一个一系列的文章来完成自己对jetpack 的了解 ...
- Android Jetpack系列之ViewModel
ViewModel介绍 ViewModel的定义:ViewModel旨在以注重生命周期的方式存储和管理界面相关的数据.ViewModel本质上是视图(View)与数据(Model)之间的桥梁,想想以前 ...
- Android Jetpack系列之Lifecycle
文章目录 Lifecycle介绍 场景case Lifecycle使用 Activity/Fragment中使用Lifecycle 自定义LifecycleOwner Application中使用Li ...
- Android Jetpack系列之LiveData
文章目录 LiveData介绍 LiveData优点 LiveData使用举例 基础用法 进阶用法 Transformations.map()修改数据源 Transformations.switchM ...
- 由多个库组成的 Android Jetpack,到底有多厉害?
序言 Jetpack 是一个由多个库组成的套件,可帮助开发者遵循最佳做法.减少样板代码并编写可在各种 Android 版本和设备中一致运行的代码,让开发者可将精力集中于真正重要的编码工作 根据官方的定 ...
- 现学现用Android Jetpack - Navigation
前言 即学即用Android Jetpack系列Blog的目的是通过学习Android Jetpack完成一个简单的Demo,本文是即学即用Android Jetpack系列Blog的第一篇. 记得去 ...
- Android Jetpack之AppCompat - Actionbar篇
今天我们来聊一聊有关AppCompat,作为Android Jetpack系列文章的开篇.说到Android Jetpack,我们先看一下这张图: 从图中我们可以看到,整个Android Jetpac ...
- Android Jetpack从入门到精通(深度好文,值得收藏)
前言 即学即用Android Jetpack系列Blog的目的是通过学习Android Jetpack完成一个简单的Demo,本文是即学即用Android Jetpack系列Blog的第一篇. 记得去 ...
- Android Jetpack组件之Hilt使用
前言 最近简单看了下google推出的框架Jetpack,感觉此框架的内容可以对平时的开发有很大的帮助,也可以解决很多开发中的问题,对代码的逻辑和UI界面实现深层解耦,打造数据驱动型UI界面. And ...
- Android Jetpack组件App Startup简析
1.前言 最近简单看了下google推出的框架Jetpack,感觉此框架的内容可以对平时的开发有很大的帮助,也可以解决很多开发中的问题,对代码的逻辑和UI界面实现深层解耦,打造数据驱动型UI界面. A ...
最新文章
- python语言基础汇总
- 苹果运行内存比较_决定手机流畅度到底是看CPU还是运行内存,你知道么?
- OneVPL与FFmpeg/GStreamer硬件编解码器
- java主程序怎样调用子程序_存过和函数以及在Java程序中的调用
- HDU 	4339	 Query
- 一亿小目标成就_成就卓越的一种方式:自我选择
- 界址点圆圈怎么生成_技巧|CASS10.1的界址点圆圈如何变细?
- 欧拉回路基本概念+判断+求解
- 怎么将几张pdf合并成一张_怎么把多个PDF合并成一个PDF?分享合并PDF文件最简单的方法...
- xml 解析库 msxml6.dll
- C++ 矩阵求a*b-1及行列式、伴随矩阵和逆矩阵思想及源代码
- ubuntu系统压力测试工具--stress
- 360公司2015年秋季校园招聘笔试考卷(技术类 D)部分试题程序验证和解析1
- 深澜系统服务器架构,S7510E-X结合深澜服务器做Portal无感知认证终端不定时掉线经验案例...
- educoder——面向对象程序设计java——实验实训——实验二 - 面向对象
- java抽象类的继承_Java,如何从抽象类继承方法
- CPU 的工作原理以及为什么Apple Silicon M1 比 Intel i9 快?
- 人工智能的创业“风口”
- mysql中查找出生日期_如何在MySQL中根据出生日期记录显示日期名称?
- 【零基础】极星量化入门十一:远程遥控的简单办法
热门文章
- 统计学 —— 单因素方差分析的应用与Excel实现
- 利用lavarel框架实现Todos App
- 智能爆炸的真实(下)
- 线程的虚假唤醒(Spurious Wakeups)以及解决方案
- Ubuntu 16.04 安装 NVIDIA GeForce GTX 1060 显卡驱动,以及 CUDA 10.1
- 【C补充】指向指针或函数的指针
- 破旧手机改造系列:最牛逼的行车记录仪
- 1.checkpoint防火墙安装以及高可靠性配置
- golang 结构体数组的初始化赋值
- 关于#pragma comment(lib,ws2_32.lib)