详解DataStore,SharedPreferences终结者

7a14c64b3bb6528caefdb09b9b8a4956.png

/   今日科技快讯   /

12月2日,港股盘中,阿里巴巴一路走跌,一度跌至创历史新低的120.1港元/股。12月1日收盘的美股,阿里巴巴也下跌3.95%,同创年内新低。单看港股方面,阿里巴巴股价从年初高点的270港元/股一路跌至120.1港元/股,年内跌幅超55%,市值蒸发约3.16万亿港元(约合人民币2.58万亿元)。

/   作者简介   /

明天就是周六啦,提前祝大家周末愉快!

本篇文章来自小马快跑的投稿,文章主要分享了DataStore的使用,相信会对大家有所帮助。同时也感谢作者贡献的精彩文章!

小马快跑的博客地址:

https://blog.csdn.net/u013700502

/   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可能会导致以下问题:

1. SP第一次加载数据时需要全量加载,当数据量大时可能会阻塞UI线程造成卡顿

2. SP读写文件不是类型安全的,且没有发出错误信号的机制,缺少事务性API

3. 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 等待(https://mp.weixin.qq.com/s/kfF83UmsGM5w43rDCH544g)。我司项目里使用的SP也是按此优化,优化后效果还是比较显著的,所以目前项目也还没有对SP进行迁移(如迁移到MMKV或DataStore),但并不影响我们学习新的存储姿势。

/   DataStore使用   /

DataStore优势:

  • DataStore基于事务方式处理数据更新。

  • DataStore基于Kotlin Flow存取数据,默认在Dispatchers.IO里异步操作,避免阻塞UI线程,且在读取数据时能对发生的Exception进行处理。

  • 不提供apply()、commit()存留数据的方法。

  • 支持SP一次性自动迁移至DataStore中。

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的文件如下:

7a5239069b533f355f65d859cca9a5c4.png

可以看到后缀名并不是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.name
        preferences[PreferenceKeys.P_KEY_BOOK_PRICE] = book.price
        preferences[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 data
    if (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] ?: 0f
    val 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文件会被删除,如下:

31df9d0a331ab1578f43911876212c09.png

Proto DataStore

SP 和 Preferences DataStore 的一个缺点是无法定义架构,保证不了存取键时使用了正确的数据类型。Proto DataStore 可利用 Protocol Buffers协议缓冲区(https://developers.google.com/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 协议缓冲区相关配置 用于DataStore
    protobuf {
        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/proto
        generatedFilesBaseDir = "$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文件都要在该声明的路径下:

7ef6ab667fe1e4c17f14e0941cdedcae.png

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代码,如下:

6192c5000b7ca10d62bfcd5ce6a0b390.png

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的文件如下:

b12eea400965faf9df253260d077c7c5.png

存数据
lifecycleScope.launch {
      //构建BookProto.Book
      val 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对比吧:

a77104421bf404bbf0ed6def4a9f83e2.png

文章中完整示例代码参见:https://github.com/crazyqiang/AndroidStudy/tree/master/app/src/main/java/org/ninetripods/mq/study/jetpack/datastore

参考

官方:使用 Jetpack DataStore 进行数据存储

https://mp.weixin.qq.com/s/26Uxotf3-oceKUbrujqX3w

官方:使用 Preferences DataStore

https://developer.android.com/codelabs/android-preferences-datastore

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

白嫖一个Android项目的类图生成工具

如何更好地使用Kotlin语法糖封装工具类

欢迎关注我的公众号

学习技术或投稿

e1a4ff084d9646606e91c85977b2c892.png

310d9ae23de69c41b86a86d98a06fe4a.png

长按上图,识别图中二维码即可关注

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值