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 的优势。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

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