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 时开启一个线程异步读取数据,最终会进入SharedPreferencesImplloadFromDisk方法:

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 执行完之后才会进行移除。

另一方面,在 ActivityServicehandleStopService()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

对比图

SharedPreferencesDataStoreMMKV 的对比:

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 迁移

迁移 SharedPreferencesDataStore 只需要 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#DartGoJavaPython 等等语言,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 的优势。

推荐阅读更多精彩内容