[Android] SharedPreference前世今生 vs. DataStore

827 阅读13分钟

引子

  • SharedPreference是什么

    Interface for accessing and modifying preference data returned by `Context#getSharedPreferences`. 
    For any particular set of preferences, there is a single instance of this class that all clients share. 
    Modifications to the preferences must go through an `Editor` object to ensure the preference values remain in a consistent state and control when they are committed to storage. 
    Objects that are returned from the various get methods must be treated as immutable by the application.
    
  • SharedPreference使用中有什么注意点

    • commitapply 的区别

      • 返回值: apply没有返回值,commit返回boolean类型的值表明修改是否成功
      • 操作效率:apply是讲修改数据提交到内存,异步提交到硬盘磁盘,commit是同步提交到硬盘磁盘上;因此多并发commit的时候,会等待正在处理的commit保存到磁盘后再执行,从而降低了效率,而apply只是提交到内存,后面调用apply的会覆盖前面的内存数据,一定程度上效率更高
      • 建议:如果对提交结果不关心,建议使用apply;如果需要确保提交成功且有后续操作,则使用commit
    • 多进程表现

      在多进程中,如果需要交换数据不建议使用SharedPreference,因为不同版本表现不稳定,推荐使用ContentProvider。 在有的文章中,有提到在多进程中使用SharePrefenerce添加标志位(MODE_MULTI_PROCESS)就可以了。这个标志位是2.3(API 9)之前默认支持的,但是在2.3之后,需要多进程的访问的情景,就需要显示的声明出来。现在这个标志位被废弃了,因为在某些版本上表现不稳定。

本文集合了若干优秀文章和官方文档,不一一列出了,(#^.^#)。

前世今生

2019的Google I/O大会上,官方推出SharedPreference的包装类 EncryptedSharedPreference 保证SharedPreference的安全性(in Jetpack Security组件)。

Android 8.0前后源码中其内部实现也有所不同,可见官方一直在尝试弥补其缺陷。

1. 设计与实现

SharedPreference是一个轻量级存储类,用来保存App的各种配置信息,本质是key-value方式保存数据到xml中,目录为data/data/package_name/shared_prefs。

届时json刚出生不久,虽然它慢慢成为流行轻量级数据存储交换格式,但是SP对Android来说却有更特殊的意义,尤其是其友好的可读性。

所以,它的本质就是通过提供一套接口,对本地存储的xml文件的key-value进行修改,修改结果也会存储在本地文件中。

2. 读操作

那么,每次读取都对文件进行读操作,再从解析到的key-value对中寻找指定key,显然性能不佳,I/O操作可优化。

于是,设计者对读操作进行了优化,当SharedPreference对象第一次通过Context.getSharedPreference()初始化的时候,对xml文件进行一次读取,并将文件内的key-value对都读取出来存到缓存map中,这样后续操作都可直接在内存中进行。

一个很明显的问题是,用内存换效率,当xml中数据量极大时,这种内存缓存机制将导致高内存占用。所以,SharedPreference适合存储轻量级频繁访问的数据!

3. 写操作

对于写操作,设计者同样提供了一系列接口以达到性能优化。

比如,我们在修改某一项值时,通常通过preferences?.edit()?.putString(key, value)?.apply()或者preferences?.edit()?.putBoolean(key, value)?.commit()实现,edit()是什么?apply()/commit()又是什么?为什么不直接通过preferences?.putString(key, value)

这是因为,在复杂的业务中,有的时候一次用户操作需要更新多个值,与其进行多次文件更新,何不将这些更新合并到一次读写操作中呢。因此,设计者抽象出一个Editor类,这也是官方定义中提出的,所有针对SP的更新都需要通过Editor来执行,而edit的值只有调用commit/apply才会真正写入文件。commit和apply的区别在上面有提到过,apply是为了实现异步执行文件数据同步,尤其是若在主线程进行频繁的commit势必引起性能退化。

通过Editor+apply,对写操作进行了优化:1. 聚集多次修改后执行同步;2. 异步同步,I/O是适合在子线程中执行的。

那么问题来了,子线程更新文件,势必需要考虑线程安全;apply异步操作能避免ANR吗?很可惜,答案是不能。

4. 数据更新 & 分文件的取舍

当xml中数量变多,一次文件读写操作也将变高。

xml中数据是如何更新的?全量还是增量? => 全量,edit会将数据更新到内存map中,commit/apply会将map的数据全量更新到xml里。所以,建议根据业务需求将数据分文件存储。

于是问题来了,为什么选择全量更新?commit/apply能保证数据同步成功吗?增量更新需要更多的内存操作和比较,通过也加大了多线程同步的问题,就像最初设计那样,轻量,所以采取全量更新,并不是增量不可,取舍而已。很不幸,虽然SP内部做了优化,但是仍然无法保证100%的同步成功,现实世界很残酷。

线程安全

SharedPreference是线程安全的,它是如何实现的呢?加锁,设计者一共加了3把锁。

1. 锁和读操作

// GuardedBy显著提升代码可读性
final class SharedPreferencesImpl implements SharedPreferences {
    @GuardedBy("mLock")
    private Map<String, Object> mMap;
    ......

    /** Latest memory state that was committed to disk */
    @GuardedBy("mWritingToDiskLock")
    private long mDiskStateGeneration;
    ......
    
    public final class EditorImpl implements Editor {
        private final Object mEditorLock = new Object();

        @GuardedBy("mEditorLock")
        private final Map<String, Object> mModified = new HashMap<>();
        ......
        }
    ......
    }

对于读操作,我们知道它通过获取mMap内存中的值并返回,通过一把锁就能实现线程安全:

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

2. 写操作

对于写操作呢?上面提到过,通过editor积攒多个change再进行更新,在Editor内部维护了mModified,当调用apply这些数据才会更新到mMap中,所以需要一把锁来维护mModified的安全-mEditorLock。它为何不与mMap共用一把锁?如果二者公用锁,不管有没有执行apply,get/put都需要相互等待,从而失去了 异步更新 的目的,所以为了保持get/put的不冲突,因为,在apply之前存储在mModified中的数据并不会应用到mMap中。

    public final class EditorImpl implements Editor {
        private final Object mEditorLock = new Object();

        @GuardedBy("mEditorLock")
        private final Map<String, Object> mModified = new HashMap<>();

        @Override
        public Editor putString(String key, @Nullable String value) {
            synchronized (mEditorLock) {
                mModified.put(key, value);
                return this;
            }
        }

当Editor的apply被执行,需要两把锁配合来保证mMap和mModified的线程安全,而将mMap数据更新到本地文件则需要另一把锁来保证。

	@Override
        public void apply() {
            ... 同步到内存中
            final MemoryCommitResult mcr = commitToMemory();
            ... 同步到文件中
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
            ... 通知
            notifyListeners(mcr);
        }
        
        // Returns true if any changes were made
        private MemoryCommitResult commitToMemory() {
            ...... SharedPreferencesImpl.this.mLock 保证 mMap 安全
            synchronized (SharedPreferencesImpl.this.mLock) {
                ...... mEditorLock 保证 mModified 安全
                synchronized (mEditorLock) {
                    ...... 同步
                    for (Map.Entry<String, Object> e : mModified.entrySet()) {
                        String k = e.getKey();
                        Object v = e.getValue();
                        // "this" is the magic value for a removal mutation. In addition, setting a value to "null" for a given key is specified to be equivalent to calling remove on that key.
                        if (v == this || v == null) {
                            if (!mapToWriteToDisk.containsKey(k)) {
                                continue;
                            }
                            mapToWriteToDisk.remove(k);
                        } else {
                            if (mapToWriteToDisk.containsKey(k)) {
                                Object existingValue = mapToWriteToDisk.get(k);
                                if (existingValue != null && existingValue.equals(v)) {
                                    continue;
                                }
                            }
                            mapToWriteToDisk.put(k, v);
                        }
                        ......
                    }

                    mModified.clear();
                    ......
                }
            }
            return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
                    listeners, mapToWriteToDisk);
        }
        
        private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
        	final boolean isFromSyncCommit = (postWriteRunnable == null);
        	final Runnable writeToDiskRunnable = new Runnable() {
                @Override
                public void run() {
                    // mWritingToDiskLock 保证文件安全
                    synchronized (mWritingToDiskLock) {
                        writeToFile(mcr, isFromSyncCommit);
                    }
                    ......
                }
            };
        	... QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    	}
        
        private static class MemoryCommitResult {
          final long memoryStateGeneration;
          final boolean keysCleared;
          @Nullable final List<String> keysModified; // 为了notify modified listener
          @Nullable final Set<OnSharedPreferenceChangeListener> listeners;
          final Map<String, Object> mapToWriteToDisk;
          final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);

          @GuardedBy("mWritingToDiskLock")
          volatile boolean writeToDiskResult = false;
          boolean wasWritten = false;
        }

最终,通过3把锁的配合实现写操作的线程安全。

3. ANR

apply初衷是通过异步避免主线程I/O的ANR,但是真的达到目的了吗?在这篇文章中可以找到答案:剖析 SharedPreference apply 引起的 ANR 问题 - 字节跳动技术团队

在apply()方法中,首先会创建一个等待锁,根据源码版本的不同,最终更新文件的任务会交给QueuedWork.singleThreadExecutor()单个线程或者HandlerThread去执行,当文件更新完毕后会释放锁。

但当Activity.onStop()以及Service处理onStop等相关方法时,则会执行QueuedWork.waitToFinish()等待所有的等待锁释放,因此如果SharedPreferences一直没有完成更新任务,有可能会导致卡在主线程,最终超时导致ANR。

什么情况下SharedPreferences会一直没有完成任务呢?比如太频繁无节制的apply(),导致任务过多。

进程安全

1. 如何保证进程安全?

源码描述Note: This class does not support use across multiple processes.

/**
 * Interface for accessing and modifying preference data returned by {@link Context#getSharedPreferences}.  
 * For any particular set of preferences, there is a single instance of this class that all clients share.
 * Modifications to the preferences must go through an {@link Editor} object  to ensure the preference values remain in a consistent state and control  when they are committed to storage.  Objects that are returned from the various <code>get</code> methods must be treated as immutable by the application.
 *
 * <p>Note: This class provides strong consistency guarantees. It is using expensive operations which might slow down an app. Frequently changing properties or properties where loss can be tolerated should use other mechanisms. For more details read the comments on {@link Editor#commit()} and {@link Editor#apply()}.
 *
 * <p><em>Note: This class does not support use across multiple processes.</em>
 */
public interface SharedPreferences {

所以SharedPreference本身是进程不安全的,那么如果我们就是有这样的业务需求,如何保证呢?

例如,加文件锁,保证每次只有一个进程在访问该文件; 例如,使用ContentProvider,在其内部定制访问SharedPreference。

曾几何时,初始化preference传入Mode可以为多进程可读可写,但是已经被抛弃了,能看到官方对于多进程的尝试和放弃:

    /**
     * File creation mode: the default mode, where the created file can only be accessed by the calling application (or all applications sharing the same user ID).
     */
    public static final int MODE_PRIVATE = 0x0000;

    /**
     * File creation mode: allow all other applications to have read access to the created file.
     * <p>
     * Starting from {@link android.os.Build.VERSION_CODES#N}, attempting to use this mode throws a {@link SecurityException}.
     *
     * @deprecated Creating world-readable files is very dangerous, and likely to cause security holes in applications. It is strongly discouraged; instead, applications should use more formal mechanism for interactions such as {@link ContentProvider}, {@link BroadcastReceiver}, and {@link android.app.Service}. There are no guarantees that this access mode will remain on a file, such as when it goes through a backup and restore.
     * @see android.support.v4.content.FileProvider
     * @see Intent#FLAG_GRANT_WRITE_URI_PERMISSION
     */
    @Deprecated
    public static final int MODE_WORLD_READABLE = 0x0001;

    /**
     * File creation mode: allow all other applications to have write access to the created file.
     * <p>
     * Starting from {@link android.os.Build.VERSION_CODES#N}, attempting to use this mode will throw a {@link SecurityException}.
     */
    @Deprecated
    public static final int MODE_WORLD_WRITEABLE = 0x0002;

    /**
     * File creation mode: for use with {@link #openFileOutput}, if the file already exists then write data to the end of the existing file instead of erasing it.
     * @see #openFileOutput
     */
    public static final int MODE_APPEND = 0x8000;

    /**
     * @deprecated MODE_MULTI_PROCESS does not work reliably in some versions of Android, and furthermore does not provide any mechanism for reconciling concurrent modifications across processes.  Applications should not attempt to use it.  Instead, they should use an explicit cross-process data management approach such as {@link android.content.ContentProvider ContentProvider}.
     */
    @Deprecated
    public static final int MODE_MULTI_PROCESS = 0x0004;

2. 文件损坏 和 备份

上面提到过,onStop的时候它会等待SP操作结束,这是为了尽可能保证数据同步成功,但是不可知原因毕竟存在,如何避免文件同步中断,失败导致数据损毁丢失呢? 答案是备份。

一般操作是,写之前将文件备份.bak,写完之后将备份文件删除;如果进程启动后发现有备份文件,则将备份文件重命名为源文件,原本未写入内容会丢失但是文件不会损坏。

关于SharedPreference常见问题

  • SharedPreference 是什么
  • SharedPreference 如何实现数据读写
  • 读操作会造成主线程阻塞吗?当然会啦,如果在主线程中get,且文件尚未初次读取完毕,则会一直等待,存在锁就存在阻塞的可能
  • 保证类型安全吗?不,键值对并不保证同一个key存储的类型不变,如果发生变化则可能导致ClassCastException,这也是升级需要考虑的问题
  • 为什么通过Editor
  • commit和apply有何区别
  • 线程安全吗?如何保证
  • 进程安全吗?如何保证
  • 异常处理:读写失败,读写中断

DataStore

定义

Jetpack DataStore is a data storage solution that allows you to store key-value pairs or typed objects with protocol buffers. DataStore uses Kotlin coroutines and Flow to store data asynchronously, consistently, and transactionally.

If you're currently using SharedPreferences to store data, consider migrating to DataStore instead.

简言之就是Jetpack DataStore是新推出的数据存储解决方案,可以用来存储键值对或者结构化对象,使用了Kotlin协成和流式数据操作,异步,一致性得到保障。

实现

DataStore 提供两种实现: Preferences DataStore and Proto DataStore.

  • Preferences DataStore stores and accesses data using keys. This implementation does not require a predefined schema, and it does not provide type safety. 使用key读写数据,不保证类型安全,不需要预定义格式。跟SP类似,但是基于Flow实现,不会阻塞主线程。
  • Proto DataStore stores data as instances of a custom data type. This implementation requires you to define a schema using protocol buffers, but it provides type safety. 使用数据对象读写,需要预定义数据格式,保证类型安全。

Preferences DataStore

setup

// Preferences DataStore (SharedPreferences like APIs)
dependencies {
  implementation "androidx.datastore:datastore-preferences:1.0.0-alpha05"
}
// Alternatively - use the following artifact without an Android dependency.
dependencies {
  implementation "androidx.datastore:datastore-preferences-core:1.0.0-alpha05"
}

读写

使用DateStorePreferences完成简单键值对的持久化。

创建

val dataStore: DataStore<Preferences> = context.createDataStore(name = "settings")

由于不需要预定义格式,所以必须通过preferencesKey()来定义需要存储到DataStore的key,通过DataStore.data属性通过Flow获取存储数据。

val EXAMPLE_COUNTER = preferencesKey<Int>("example_counter")
val exampleCounterFlow: Flow<Int> = dataStore.data
  .map { preferences ->
    // No type safety.
    preferences[EXAMPLE_COUNTER] ?: 0
}

类似SP,DataStore提供edit()方法更新DataStore中的数据,但是又非常不同,其transform会将一段代码看做原子操作进行执行。

suspend fun incrementCounter() {
  dataStore.edit { settings ->
    val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
    settings[EXAMPLE_COUNTER] = currentCounterValue + 1
  }
}

Proto DataStore

setup

// Typed DataStore (Typed API surface, such as Proto)
dependencies {
  implementation "androidx.datastore:datastore:1.0.0-alpha05"
}
// Alternatively - use the following artifact without an Android dependency.
dependencies {
  implementation "androidx.datastore:datastore-core:1.0.0-alpha05"
}

读写

定义格式

Proto DataStore必须指定一个预定义的格式文件,app/src/main/proto目录下,它制定了需要持久化的数据类型。更多参考 protobuf language guide.

syntax = "proto3";

option java_package = "com.example.application";
option java_multiple_files = true;

message Settings {
  int example_counter = 1;
}
  • 注意,存储对象类是编译时生成,所以确保项目工程重新build。

创建

分为两个步骤:

  • 定义一个类继承自Serializer<T>,而T是proto files中定义的类型。这个操作是告诉DataStore如何读写数据,需要确保该类型有初始值,当创建文件时需要用到。
  • 使用Context.createDataStore()创建DataStore<T>实例, filename变量告诉DataStore用哪个文件存储数据,serializer变量告诉DataStore是哪个序列化类,也即上一步中定义的。
object SettingsSerializer : Serializer<Settings> {
  override val defaultValue: Settings = Settings.getDefaultInstance()

  override fun readFrom(input: InputStream): Settings {
    try {
      return Settings.parseFrom(input)
    } catch (exception: InvalidProtocolBufferException) {
      throw CorruptionException("Cannot read proto.", exception)
    }
  }

  override fun writeTo(t: Settings, output: OutputStream) = t.writeTo(output)
}

val settingsDataStore: DataStore<Settings> = context.createDataStore(fileName = "settings.pb", serializer = SettingsSerializer)

val exampleCounterFlow: Flow<Int> = settingsDataStore.data
  .map { settings ->
    // The exampleCounter property is generated from the proto schema.
    settings.exampleCounter
  }

提供updateData方法。

suspend fun incrementCounter() {
  settingsDataStore.updateData { currentSettings ->
    currentSettings.toBuilder()
      .setExampleCounter(currentSettings.exampleCounter + 1)
      .build()
    }
}

在同步代码中使用DataStore

Caution: Avoid blocking threads on DataStore data reads whenever possible. Blocking the UI thread can cause ANRs or UI jank, and blocking other threads can result in deadlock.!

DataStore的主要好处是异步API,但是它并不意味着将周围代码改为异步执行是可行的,例如使用了同步磁盘I/O库,或者不提供异步API的依赖库。

Kotlin携程提供runBlocking生成器,弥补同步和异步代码之间的差距,可以用它同步从DataStore读取数据。

// 阻塞调用线程,直到DataStore返回数据为止
val exampleData = runBlocking { dataStore.data.first() }

在主线程中执行同步I/O操作可能导致ANR、UI jank,可以通过异步预加载DataStore数据优化:

override fun onCreate(savedInstanceState: Bundle?) {
    lifecycleScope.launch {
        dataStore.data.first()
        // You should also handle IOExceptions here.
    }
}

这样,DataStore异步读取数据并缓存到内存中,下次同步兑取时则会更快且当读取完成时,有可能避免同时的I/O操作。

迁移SP到DataStore

引入依赖库

implementation "androidx.datastore:datastore-preferences:1.0.0-alpha05"

创建DataStore时指定SP

在原来获取SharedPreference的地方,替换成创建DataStore同时完成迁移。

	// original
	fun initPreference(context: Context, fileName: String, mode: Int) {
        preferences = context.getSharedPreferences(fileName, mode)
	}

	// now
	fun initPreference(context: Context, fileName: String) {
        try {
            if (isPreferenceMigrated(fileName)) {
                dataStore = context.createDataStore(fileName)
            } else {
                dataStore = context.createDataStore(
                    fileName,
                    migrations = listOf(SharedPreferencesMigration(context, fileName))
                )
                setPreferenceMigrated(fileName)
            }
        } catch (e: Exception) {
        }
    }

getXXX重写

	// original
    fun getString(key: String, defaultValue: String): String {
        return preferences?.getString(key, defaultValue) ?: defaultValue
    }
    
    // now
    fun getString(key: String, defaultValue: String): String {
        val pKey = preferencesKey<String>(key)
        return runBlocking {
            dataStore?.data?.catch { it.printStackTrace() }
                ?.map { p -> p[pKey] ?: defaultValue }?.first()
                ?: defaultValue
        }
    }

setXXX重写

	// original
    fun putString(key: String, value: String) {
        preferences?.edit()?.putString(key, value)?.apply()
    }
    
    // now
    fun putString(key: String, value: String) {
        val pKey = preferencesKey<String>(key)
        runBlocking {
            dataStore?.edit { it[pKey] = value }
        }
    }

以上是DataStore的使用,关于它所保证的一些特性和好处,由于牵扯到的机制较多,下次再详细说明。