15

Jetpack:DataStore必知的几个优点

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzIzNTc5NDY4Nw%3D%3D&%3Bmid=2247484843&%3Bidx=1&%3Bsn=3f41d72f98ddf62f49264b8753bfd331
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

最近 Jetpack 又增加了新成员,提出了一个关于小型数据存储相关的 DataStore 组件。

根据官网的描述, DataStore 完全是对标现有的 SharedPreferences

SharedPreferences 相信大家都有用过,既然在现有的基础上提出 DataStore 那自然是为了解决 SharedPreferences 的缺点的。

如果你还不知道 SharedPreferences 有什么缺点?没关系,我们正好来复习一遍。你可以对标一下在使用 SharedPreferences 的过程中是否也遇到过这些问题。

SharedPreferences的糟心事

为了精简语言,下面都将 SharedPreferences 简称 sp

一次性读取阻塞主线程

sp = getSharedPreferences("settings_preference", Context.MODE_PRIVATE)

在使用 sp 的过程中,会通过 getSharedPreferences 来初始化 sp

上面这段代码最终会进入 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 方法,例如 getString 方法

public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}
 
private void awaitLoadedLocked() {
    if (!mLoaded) {
        // Raise an explicit StrictMode onReadFromDisk for this
        // thread, since the real read will be in a different
        // thread and otherwise ignored by StrictMode.
        BlockGuard.getThreadPolicy().onReadFromDisk();
    }
    while (!mLoaded) {
        try {
            mLock.wait(); // 等待sp文件读取完毕
        } catch (InterruptedException unused) {
        }
    }
    if (mThrowable != null) {
        throw new IllegalStateException(mThrowable);
    }
}

这里会在 awaitLoadedLocked 方法中调用 mLock.wait() 来等待 sp 的初始化完成。

所以如果 sp 文件过大,初始化所花的时间过多,会导致后面 sp 数据获取时的阻塞。

类型不安全

在我们使用 sp 过程中,用的最多的应该是它的 putget 方法。现在我们用这两个方法来写一段代码

sp = getSharedPreferences("settings_preference", Context.MODE_PRIVATE)
 
// 某一个地方的逻辑
sp.edit().putString("key_name_from_sp", "from sp").apply()
 
// 另一个地方的逻辑
sp.edit().putInt("key_name_from_sp", "from sp").apply()
 
// 获取key_name_from_sp值
sp.getString("key_name_from_sp", "")

如果你运行上面的代码你可以发现程序运行异常,本质问题是对同一个 key 赋值了不同类型的值。将原来 String 类型的值转变成 Int 类型。由于 sp 内部是通过 Map 来保存对于的 key-value ,所以它并不能保证 key-value 的类型固定,也进一步导致通过 get 方法来获取对应 key 的值的类型也是不安全的。这就造成了所谓的类型不安全。

public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

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

apply异步没有回调

为了防止 sp 写入时阻塞线程,一般都会使用 apply 方法来将数据异步提交到磁盘,即写入到文件中。

虽然 apply 是异步,但它并没有返回值,同样也没有对应的结果回调。

public void apply() {
 ...
}

导致ANR

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

ANR 的产生是主线程长时间未响应导致的。 apply 不是异步的吗?它怎么又会产生 ANR 呢?

来看下 apply 的源码

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

这是一方面,我们再来看另一方面。

ActivityonPauseonStopServiceonDestory 中会等待 QueuedWork 中的任务全部完成,一旦 QueuedWork 中的任务非常耗时,例如 sp 的写入磁盘数据量过多,就会导致主线程长时间未响应,从而产生 ANR

具体调用分别在 ActivityThread 中的 handlePauseActivityhandlePauseActivityhandleStopService 方法中。

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;
    }
}

那如何解决呢?首先使用 sp 不要存储过大的 key-value 数据,本身 sp 就是轻量的存储,对于大数据还是使用 room 来存储。

此类 ANR 都是经由 QueuedWork.waitToFinish() 触发的,如果在调用此函数之前,将其中保存的队列手动清空,那么是不是能解决问题呢,答案是肯定的。

另外在今日头条的一篇文章中已经提出解决 ANR 的方法,具体解决可以自行查看

https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247484387&idx=1&sn=e3c8d6ef52520c51b5e07306d9750e70&scene=21#wechat_redirect

不能跨进程通信

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 只是重新读取一遍文件而已,并不能保证跨进程通信。

上面的 sp 问题不知道你在使用的过程中是否有遇到过,或者说有幸中标几条,大家可以留言来对比一下,说出你的故事(此处应该有酒)。

DataStore

针对 sp 那几个问题, DataStore 都够能规避。为了精简语言,下面都将 DataStore ds

  1. ds
    kotlin
    ANR
    
  2. ds
    sp
    protocol buffers
    protocol buffers
    
  3. ds 能够在编译阶段提醒 sp 类型错误,保证 sp 类型的类型不安全问题。
  4. ds
    Flow
    Flow
    
  5. ds 完美支持 sp 数据的迁移,你可以无成本过渡到 ds

ds 将会是 Android 后续轻量数据存储的首选组件。我们也是时候来了解 ds 的使用。

引入DataStore

首先我们要引入 ds ,方式很简单直接在 build 中添加依赖即可。唯一需要注意的是 ds 支持 spprotocol buffers 两种类型,所以对应的也有两种依赖。

// Preferences DataStore
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"
 
// Proto DataStore
implementation  "androidx.datastore:datastore-core:1.0.0-alpha01"

下面针对这两种类型分别做介绍。

创建DataStore

针对 sp 类型的数据, ds 只需通过 createDataStore 方法来获取对应的 ds 对象

private val dataStore = createDataStore("settings")

其中 settings 为对应的文件名,存储方式为 datastore/ + name + .preferences_pb

protocol buffers 类型需要额外实现 Serializer 接口,提供读写的入口。

object SettingsSerializer : Serializer<Settings> {
 
    override fun readFrom(input: InputStream): Settings {
        return Settings.parseFrom(input)
    }
 
    override fun writeTo(t: Settings, output: OutputStream) {
        t.writeTo(output)
    }
 
}
 
private val dataStoreProto = createDataStore("settings.pb", SettingsSerializer)

其中的 Settings 类是通过 protocol buffers 脚本自动生成的。要生成 Settings 类,你需要做两件事

  1. 配置 protocol buffers 环境
  2. 编写 .proto 文件

所以你可能需要懂一点 protocol buffers 相关的语法。

如果后续有空,可能会单独开文章介绍一下 protocol buffers 相关的内容,大厂用的基本上都是 protocol buffers

syntax = "proto3";

option java_multiple_files = true;

message Settings {
    string key_name = 1;
}

使用 protocol buffers 运行上面的代码就能自动帮我们生成对应的 Settings 类。其中它里面的一个变量就是 keyName_ ,它是 String 类型。通过创建类与对应变量的方式来约定类型的安全。

spprotocol buffers 类型的读操作使用方式都一样,首先都要创建 Preferences.Key 类型的 key

val DATA_KEY = preferencesKey<String>("key_name")

对应的 preferencesKey 如下:

inline fun <reified T : Any> preferencesKey(name: String): Preferences.Key<T> {
    return when (T::class) {
        Int::class -> {
            Preferences.Key<T>(name)
        }
        String::class -> {
            Preferences.Key<T>(name)
        }
        Boolean::class -> {
            Preferences.Key<T>(name)
        }
        Float::class -> {
            Preferences.Key<T>(name)
        }
        Long::class -> {
            Preferences.Key<T>(name)
        }
        Set::class -> {
            throw IllegalArgumentException("Use `preferencesSetKey` to create keys for Sets.")
        }
        else -> {
            throw IllegalArgumentException("Type not supported: ${T::class.java}")
        }
    }
}

它支持 IntStringBooleanFloatLong 类型的数据,另外还有一个 preferencesSetKey ,用来支持 set 类型的数据。

调用 preferencesKey 每次都创建一个 Preferences.Key 对象,那它这样如何保证是同一个 key 呢?

如果你去看源码就会一目了然。

internal constructor(val name: String) {
    override fun equals(other: Any?) =
        if (other is Key<*>) {
            name == other.name
        } else {
            false
        }
 
    override fun hashCode(): Int {
        return name.hashCode()
    }
}

原来是它重写了 equals 方法,内部实现对 name 的比较。那么只要创建 preferencesKey 时传入的 name 相同,就能保证获取到的是同一个 key 的数据。

有了 key ,再来通过 dataStore.data.map 来获取 Flow ,同时暴露出对应的 Preferences

private suspend fun read() {
    dataStore.data.map {
        // unSafe type
        if (it[DATA_KEY] is String) {
            it[DATA_KEY] ?: ""
        } else {
            "type is String: ${it[DATA_KEY] is String}"
        }
    }.collect {
        Toast.makeText(this@DataStoreActivity, "read result: $it", Toast.LENGTH_LONG).show()
    }
}

同时在 read 中写了一个验证 SharedPreference 类型不安全的示例。如果在别的地方赋值了 DATA_KEYString 类型的数据时,将会弹出 else 中的语句。

下面是 protocol buffers 的读取

private suspend fun protoRead() {
    dataStoreProto.data.map {
        // safe type
        it.keyName
    }.collect {
        Toast.makeText(this, "read result success form proto: $it", Toast.LENGTH_LONG).show()
    }
}

需要注意的是这里获取的数据就是类型安全的。这里的 it 对应的就是在 ds 创建时产生的 Settings

spprotocol buffers 有所不同。

对于 sp 直接使用 dataStore.edit 来写入数据

private suspend fun write(value: String) {
    dataStore.edit {
        it[DATA_KEY] = value
        LogUtils.d("dataStore write: $value")
    }
}

protocol buffers 使用的是 updateData 方法

private suspend fun protoWrite(value: String) {
    dataStoreProto.updateData {
        it.toBuilder().setKeyName(value).build()
    }
}

迁移SharedPreferences

迁移也分为两种,一种是迁移到 dssp 中;另一种是迁移到 protocol buffers 中。

具体来看,如果迁移到 dssp 中,只需在之前创建 ds 基础上额外再加一个 migrations 参数。

private val dataStore = createDataStore("settings", migrations = listOf(SharedPreferencesMigration(this, "settings_preference")))

通过创建 SharedPreferencesMigration 来迁移对应的 sp 数据。

下面是迁移到 protocol buffers

val settingsDataStore: DataStore<Settings> = context.createDataStore(
    produceFile = { File(context.filesDir, "settings.preferences_pb") },
    serializer = SettingsSerializer,
    migrations = listOf(
        SharedPreferencesMigration(
            context,
            "settings_preferences"            
        ) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences ->
            // Map your sharedPrefs to your type here
          }
    )
)

迁移完之后需要执行一次代码,同时应该停止再次使用 sp 。如果迁移成功将会删除之前 sp.xml 类型的文件,生成对应 ds 文件。

最后附上一张 Google 分析的 SharedPreferencesDataStore 的区别图

uqUrMna.png!mobile

目前可以看到 DataStore 还处在 alpha 版本,非常期待它之后的正式版本。

另外,针对 DataStore 的使用,我写了一个 demo ,大家 可以在 android-api-analysis 获取。

https://github.com/idisfkj/android-api-analysis

你也可以点击阅读原文查看。

推荐阅读

算法之旅:复杂度分析

一文读懂蛋壳暴雷事件

Android Startup最新进展

原创不易,感谢支持

N7zqQb2.jpg!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK