DataStore

Jetpack 的 DataStore 是一种数据存储解决方案,可以像 SharedPreferences 一样存储键值对或使用 protocol buffers 存储类型化的对象。 DataStore 使用 Kotlin 的协程和 Flow 以异步的、一致性的、事务性的方式来存储数据,对比 SharedPreferences 有许多改进和优化,主要作为 SharedPreferences 的替代品,并且由 SharedPreferences 迁移非常方便。

DataStore 提供了两种方式:

  • Preferences DataStore:以键值对的形式存储在本地,和 SP 类似,但是 DataStore 是基于 Flow 实现的,不会阻塞主线程,但不能保证类型安全。

  • Proto DataStore:存储自定义数据类型的对象(typed objects),通过 protocol buffers 将对象序列化存储在本地,这要求通过 protocol buffers 预先定义 schema,但是能保证类型安全。

既然 DataStore 是 SP 的替代和改进,那 SP 存在着什么问题需要被改进呢?

SharedPreferences 的不足

SharedPreference 是一个轻量级的数据存储方式,使用起来非常方便,以键值对的形式存储在本地,但存在以下问题:

通过 getXXX() 方法获取数据,可能会导致主线程阻塞

所有 getXXX() 方法都是同步的,在主线程调用 get 方法,必须等待 SP 加载完毕,初始化 SP 的时候,会将整个 xml 文件内容加载内存中,如果文件很大,读取较慢,会导致主线程阻塞。

val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 异步加载 SP 文件内容
sp.getString("jetpack", ""); // 等待 SP 加载完毕

getSharedPreferences 时开启一个线程异步读取数据,最终会进入SharedPreferencesImpl的loadFromDisk方法:

private void loadFromDisk() {
    synchronized (mLock) {
        if (mLoaded) {
            return;
        }
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }
 
    // Debugging
    if (mFile.exists() && !mFile.canRead()) {
        Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
    }
 
    Map<String, Object> map = null;
    StructStat stat = null;
    Throwable thrown = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                str = new BufferedInputStream(
                        new FileInputStream(mFile), 16 * 1024);
                map = (Map<String, Object>) XmlUtils.readMapXml(str);
            } catch (Exception e) {
                Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        // An errno exception means the stat failed. Treat as empty/non-existing by
        // ignoring.
    } catch (Throwable t) {
        thrown = t;
    }
 
    synchronized (mLock) {
        mLoaded = true;
        mThrowable = thrown;
 
        // It's important that we always signal waiters, even if we'll make
        // them fail with an exception. The try-finally is pretty wide, but
        // better safe than sorry.
        try {
            if (thrown == null) {
                if (map != null) {
                    mMap = map;
                    mStatTimestamp = stat.st_mtim;
                    mStatSize = stat.st_size;
                } else {
                    mMap = new HashMap<>();
                }
            }
            // In case of a thrown exception, we retain the old map. That allows
            // any open editors to commit and store updates.
        } catch (Throwable t) {
            mThrowable = t;
        } finally {
            mLock.notifyAll();
        }
    }
}

在这里通过对象锁 mLock机制来对其进行加锁操作。只有当 SP 文件中的数据全部读取完毕之后才会调用mLock.notifyAll() 来释放锁,而 get 方法会在 awaitLoadedLocked 方法中调用 mLock.wait()来等待SP 的初始化完成。所以虽然这是异步方法,但当读取的文件比较大时,还没读取完,接着调用 getXXX() 方法需等待其完成,就可能导致主线程阻塞。

SharedPreference 不能保证类型安全

调用 getXXX() 方法的时候,可能会出现 ClassCastException 异常,因为使用相同的 key 进行操作的时候,putXXX 方法可以使用不同类型的数据覆盖掉相同的 key。

val key = "jetpack" 
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) 
sp.edit { putInt(key, 0) } // 使用 Int 类型的数据覆盖相同的 key 
sp.getString(key, ""); // 使用相同的 key 读取 Sting 类型的数据

由于 SP 内部是通过Map来保存对于的key-value,所以它并不能保证key-value的类型固定,导致通过get方法来获取对应key的值的类型也是不安全的。

在getString的源码中,会进行类型强制转换,如果类型不对就会导致程序崩溃。由于SP不会在代码编译时进行提醒,只能在代码运行之后才能发现,避免不掉可能发生的异常。

SharedPreference 加载的数据会一直留在内存中,浪费内存

通过 getSharedPreferences() 方法加载的数据,最后会将数据存储在静态的成员变量中。静态的 ArrayMap 缓存每一个 SP 文件,而每个 SP 文件内容通过 Map 缓存键值对数据,这样数据会一直留在内存中,浪费内存。

apply() 方法虽然是异步的,仍可能会发生 ANR

apply 异步提交解决了线程的阻塞问题,但如果 apply 任务过多数据量过大,可能会导致ANR的产生。

apply() 方法不是异步的吗,为什么还会造成 ANR 呢?apply() 方法本身没有问题,但是当生命周期处于 handleStopService() 、 handlePauseActivity() 、 handleStopActivity() 的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。

public void apply() {
    final long startTime = System.currentTimeMillis();
 
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }

                if (DEBUG && mcr.wasWritten) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " applied after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
        };
 
    // 注意:将awaitCommit添加到队列中
    QueuedWork.addFinisher(awaitCommit);
 
    Runnable postWriteRunnable = new Runnable() {
            @Override
            public void run() {
                awaitCommit.run();
                // 成功写入磁盘之后才将awaitCommit移除
                QueuedWork.removeFinisher(awaitCommit);
            }
        };
 
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
 
    // Okay to notify the listeners before it's hit disk
    // because the listeners should always get the same
    // SharedPreferences instance back, which has the
    // changes reflected in memory.
    notifyListeners(mcr);
}

这里关键点是会将 awaitCommit 加入到 QueuedWork 队列中,只有当 awaitCommit 执行完之后才会进行移除。

另一方面,在 Activity 和 Service 的 handleStopService() 、 handlePauseActivity() 、 handleStopActivity() 中会等待 QueuedWork 中的任务全部完成,一旦 QueuedWork 中的任务非常耗时,例如 SP 的写入磁盘数据量过多,就会导致主线程长时间未响应,从而产生 ANR:

public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving,
        int configChanges, PendingTransactionActions pendingActions, String reason) {
    ActivityClientRecord r = mActivities.get(token);
    if (r != null) {
        if (userLeaving) {
            performUserLeavingActivity(r);
        }
 
        r.activity.mConfigChangeFlags |= configChanges;
        performPauseActivity(r, finished, reason, pendingActions);

        // Make sure any pending writes are now committed.
        if (r.isPreHoneycomb()) {
            //等待任务完成
            QueuedWork.waitToFinish();
        }
        mSomeActivitiesChanged = true;
    }
}

SharedPreference 不能跨进程通信

SP 是不能跨进程通信的,虽然在获取 SP 时提供了MODE_MULTI_PROCESS,但内部并不是用来跨进程的。

public SharedPreferences getSharedPreferences(File file, int mode) {
    if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
        getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
        // 重新读取SP文件内容
        sp.startReloadIfChangedUnexpectedly();
    }
    return sp;
}

在这里使用 MODE_MULTI_PROCESS 只是重新读取一遍文件而已,并不能保证跨进程通信。

apply() 方法没有结果回调

为了防止 SP 写入时阻塞线程,一般都会使用 apply 方法来将数据异步写入到文件中,但它无法有返回值,也没有对应的结果回调,所以无法得知此次写入结果是成功还是失败。

DataStore 有哪些改进

针对 SP 的几个问题,DataStore 都够能规避。

  • DataStore 内部使用 kotlin 协程通过挂起的方式来避免阻塞线程,避免产生 ANR。
  • DataStore 不仅支持 SP 同时还支持 protocol buffers 类型的存储,protocol buffers 是可以保证数据类型安全的。
  • DataStore 能够在编译阶段提醒 SP 类型错误,减少写代码时的失误导致类型不安全问题。
  • DataStore 使用 Flow 来获取数据,每次保存数据之后都会通知最近的 Flow,可以获得到操作成功或失败的结果。
  • DataStore 完美支持 SP 数据的迁移,可以无成本过渡到 DataStore。

对比图

SharedPreferences、DataStore、MMKV 的对比:

DataStore 的使用和迁移

Preferences DataStore

添加依赖
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01" 
构建 DataStore
private val PREFERENCE_NAME = "DataStore"
var dataStore: DataStore<Preferences> = context.createDataStore(
    name = PREFERENCE_NAME

存储位置为 data/data/包名/files/datastore/ + PREFERENCE_NAME + .preferences_pb

读取数据

注意:Preferences DataStore 只支持 Int , Long , Boolean , Float , String 这几种键值对数据。

val KEY_BYTE_CODE = preferencesKey<Boolean>("ByteCode")

fun readData(key: Preferences.Key<Boolean>): Flow<Boolean> =
    dataStore.data
        .map { preferences ->
            preferences[key] ?: false
        }

dataStore.data 会返回一个 Flow<T>,每当数据变化的时候都会重新发出。

写入数据
suspend fun saveData(key: Preferences.Key<Boolean>) {
    dataStore.edit { mutablePreferences ->
        val value = mutablePreferences[key] ?: false
        mutablePreferences[key] = !value
    }
}

通过 DataStore.edit() 写入数据的,DataStore.edit() 是一个 suspend 函数,所以只能在协程体内使用。

从 SharedPreferences 迁移

迁移 SharedPreferences 到 DataStore 只需要 2 步。

  • 构建 DataStore 的时候,需要传入一个 SharedPreferencesMigration
dataStore = context.createDataStore(
    name = PREFERENCE_NAME,
    migrations = listOf(
        SharedPreferencesMigration(
            context,
            SharedPreferencesRepository.PREFERENCE_NAME
        )
    )
)
  • 当 DataStore 对象构建完了之后,需要执行一次读取或者写入操作,即可完成 SharedPreferences 迁移到 DataStore,当迁移成功之后,会自动删除 SharedPreferences 使用的文件。

注意: 只从 SharedPreferences 迁移一次,因此一旦迁移成功之后,应该停止使用 SharedPreferences。

Proto DataStore

Protocol Buffers:是 Google 开源的跨语言编码协议,可以应用到 C++ 、C# 、Dart 、Go 、Java 、Python 等等语言,Google 内部几乎所有 RPC 都在使用这个协议,使用了二进制编码压缩,体积更小,速度比 JSON 更快,但是缺点是牺牲了可读性。

Proto DataStore 通过 protocol buffers 将对象序列化存储在本地,比起 Preference DataStore 支持更多类型,使用二进制编码压缩,体积更小速度更快。使用 Proto DataStore 需要先引入 protocol buffers。

本文只对 Proto DataStore 做简单介绍。

添加依赖
// Proto DataStore
implementation "androidx.datastore:datastore-core:1.0.0-alpha01"
// protobuf
implementation "com.google.protobuf:protobuf-javalite:3.10.0"

当添加完依赖之后需要新建 proto 文件,在本文示例项目中新建了一个 common-protobuf 模块,将新建的 person.proto 文件,放到了 common-protobuf 模块 src/main/proto 目录下。

在 common-protobuf 模块,build.gradle 文件内,添加以下依赖:

implementation "com.google.protobuf:protobuf-javalite:3.10.0"
新建 Person.proto 文件,添加以下内容
syntax = "proto3";

option java_package = "com.hi.dhl.datastore.protobuf";
option java_outer_classname = "PersonProtos";

message Person {
    // 格式:字段类型 + 字段名称 + 字段编号
    string name = 1;
}
执行 protoc ,编译 proto 文件
protoc --java_out=./src/main/java -I=./src/main/proto  ./src/main/proto/*.proto
构建 DataStore
object PersonSerializer : Serializer<PersonProtos.Person> {
    override fun readFrom(input: InputStream): PersonProtos.Person {
        try {
            return PersonProtos.Person.parseFrom(input) // 是编译器自动生成的,用于读取并解析 input 的消息
        } catch (exception: Exception) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override fun writeTo(t: PersonProtos.Person, output: OutputStream) = t.writeTo(output) // t.writeTo(output) 是编译器自动生成的,用于写入序列化消息
}
读取数据
fun readData(): Flow<PersonProtos.Person> {
    return protoDataStore.data
        .catch {
            if (it is IOException) {
                it.printStackTrace()
                emit(PersonProtos.Person.getDefaultInstance())
            } else {
                throw it
            }
        }
写入数据
suspend fun saveData(personModel: PersonModel) {
    protoDataStore.updateData { person ->
        person.toBuilder().setAge(personModel.age).setName(personModel.name).build()
    }
}
从 SharedPreferences 迁移
  • 创建映射关系

  • 构建 DataStore 并传入 shardPrefsMigration

  • 执行一次读取或者写入操作

SuperApp引入

SuperApp 当前使用 SP 实现小数据存取,具体由 IPCConfig 工具类封装 SP 提供静态方法供各处使用。鉴于 DataStore 的各项改进及迁移非常方便,可以考虑从 SP 迁移到 DataStore。

Proto DataStore 虽然有更多优势,但需要引入Protocol Buffers,同时开发者需要如 proto 语法等更多的学习成本,使用和迁移也会稍微麻烦些。考虑到现在暂时没有 Proto DataStore 对应的使用场景,可以先迁移到 Preferences DataStore,后续如有需要再做处理。

初步改写 IPCConfig:

const val SHARED_PREFERENCES_NAME = "com.tplink.superapp_preferences"
const val DATA_STORE_NAME = "IPCConfig"

object IPCConfig {

    private var mDataStore: DataStore<Preferences>? = null

    @JvmStatic
    fun putBoolean(context: Context?, key: String?, flag: Boolean) {
        setConfig(context, key, flag)
    }

    @JvmStatic
    fun getBoolean(context: Context?, key: String?, defaultValue: Boolean): Boolean {
        return getConfig(context, key, defaultValue)
    }

    @JvmStatic
    fun putInt(context: Context?, key: String?, num: Int) {
        setConfig(context, key, num)
    }

    @JvmStatic
    fun getInt(context: Context?, key: String?, defaultValue: Int): Int {
        return getConfig(context, key, defaultValue)
    }

    @JvmStatic
    fun putString(context: Context?, key: String?, value: String) {
        setConfig(context, key, value)
    }

    @JvmStatic
    fun getString(context: Context?, key: String?, defaultValue: String): String {
        return getConfig(context, key, defaultValue)
    }

    @JvmStatic
    fun putLong(context: Context?, key: String?, value: Long) {
        setConfig(context, key, value)
    }

    @JvmStatic
    fun getLong(context: Context?, key: String?, defaultValue: Long): Long {
        return getConfig(context, key, defaultValue)
    }

    private fun getDataStore(context: Context): DataStore<Preferences>? {
        if (mDataStore == null) {
            mDataStore = context.createDataStore(
                name = DATA_STORE_NAME,
                migrations = listOf(
                    SharedPreferencesMigration(
                        context,
                        SHARED_PREFERENCES_NAME
                    )
                )
            )
        }
        return mDataStore
    }

    private inline fun <reified T : Any> getConfig(
        context: Context?,
        key: String?,
        defaultValue: T
    ): T {
        if (context == null || key == null) {
            return defaultValue
        }
        return runBlocking {
            getDataStore(context)?.data
                ?.catch {
                    // 当读取数据遇到错误时,如果是IOException异常,发送一个emptyPreferences重新使用
                    // 但是如果是其他的异常,最好将它抛出去,不要隐藏问题
                    it.printStackTrace()
                    if (it is IOException) {
                        emit(emptyPreferences())
                    } else {
                        throw it
                    }
                }?.map {
                    it[preferencesKey<T>(key)] ?: defaultValue
                }?.first() ?: defaultValue
        }
    }

    private inline fun <reified T : Any> setConfig(context: Context?, key: String?, value: T) {
        if (context == null || key == null) {
            return
        }
        GlobalScope.launch {
            getDataStore(context)?.edit {
                it[preferencesKey<T>(key)] = value
            }
        }
    }
}

迁移前后文件结构:

测试可正常使用。

这样修改可以只改变一个文件,各调用处无需变动,就完成到 Preferences DataStore 的迁移,但是 get 方法都是 runBlocking 同步方法,没有使用到 DataStore 的全部功能。这里只是为了简单验证下迁移的可行性和便捷性,后续可以继续优化充分利用好 DataStore 的优势。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • DataStore出现的原因 Jetpack DataStore is a data storage soluti...
    不做android阅读 1,620评论 1赞 0
  • 今天感恩节哎,感谢一直在我身边的亲朋好友。感恩相遇!感恩不离不弃。 中午开了第一次的党会,身份的转变要...
    余生动听阅读 11,083评论 0赞 11
  • 彩排完,天已黑
    刘凯书法阅读 4,704评论 1赞 3
  • 没事就多看看书,因为腹有诗书气自华,读书万卷始通神。没事就多出去旅游,别因为没钱而找借口,因为只要你省吃俭用,来...
    向阳之心阅读 5,127评论 3赞 11
  • 表情是什么,我认为表情就是表现出来的情绪。表情可以传达很多信息。高兴了当然就笑了,难过就哭了。两者是相互影响密不可...
    Persistenc_6aea阅读 131,627评论 2赞 7

友情链接更多精彩内容