官方也无力回天?反思Android SharedPreferences的设计与实现

码个蛋(codeegg) 第 1093 次推文

作者:却把清梅嗅

链接:https://juejin.im/post/6884505736836022280

起源

就在前几日,有幸拜读到 HiDhl 的文章,继腾讯开源类似功能的MMKV之后,Google官方维护的 Jetpack DataStore 组件横空出世——这是否意味着无论是腾讯三方还是Google官方的角度,SharedPreferences都彻底告别了这个时代?

无论是MMKV的支持者还是DataStore的拥趸,SharedPreferences似乎都不值一提;值得深思的是,笔者通过面试或者其它方式,和一些同行交流时,却遇到了以下的情形:

在谈及SharedPreferences和MMKV,大多数人都能对前者的 缺陷,以及后者性能上若干 数量级的优势 娓娓道来;但是,在针对前者的短板进行细节化的讨论时,往往却得不到更深入性的结果,简单列举几个问题如下:

  • SharedPreferences是如何保证线程安全的,其内部的实现用到了哪些锁?

  • 进程不安全是否会导致数据丢失?

  • 数据丢失时,其最终的屏障——文件备份机制是如何实现的?

  • 如何实现进程安全的SharedPreferences?

除此之外,站在 设计者的角度 上,还有一些与架构相关,且同样值得思考的问题:

  • 为什么SharedPreferences会有这些缺陷,如何对这些缺陷做改进的尝试?

  • 为什么不惜推倒重来,推出新的DataStore组件来代替前者?

  • 令Google工程师掣肘,时隔今日,这些缺陷依然存在的最根本性原因是什么?

而想要解除这些潜藏在内心最深处的困惑,就不得不从SharedPreferences本身的设计与实现讲起了。

本文大纲如下:

一、SharedPreferences的前世今生

我们知道,就在不久前2019年的Google I/O大会上,官方推出了Jetpack Security组件,旨在保证文件和SharedPreferences的安全性,SharedPreferences的包装类,EncryptedSharedPreferences隆重登场。

不仅如此,Android 8.0前后的源码中,SharedPreferences内部的实现也略有不同。由此可见,Android官方一直在尽力“拯救”SharedPreferences。

因此,在毅然决然抛弃SharedPreferences投奔新的解决方案之前,我们有必要重新认识一下它。

1、设计与实现:建立基本结构

SharedPreferences是Android平台上 轻量级的存储类,用来保存App的各种配置信息,其本质是一个以 键值对(key-value)的方式保存数据的xml文件,其保存在/data/data/shared_prefs目录下。

对于21世纪初,那个Android系统诞生的时代而言,使用xml文件保存应用轻量级的数据绝对是一个不错的主意。那个时代的json才刚刚出生不久,虽然也渐渐成为了主流的 轻量级数据交换格式 ,但是其更多的优势还是在于 可读性,这也是笔者猜测没有使用json而使用xml保存的原因之一。

现在我们为这个 轻量级的存储类 建立了最基础的模型,通过xml中的键值对,将对应的数据保存到本地的文件中。这样,每次读取数据时,通过解析xml文件,得到指定key对应的value;每次更新数据,也通过文件中key更新对应的value。

2、读操作的优化

通过这样的方式,虽然我们建立了一个最简单的 文件存储系统,但是性能实在不敢恭维,每次读取一个key对应的值都要重新对文件进行一次读的操作?显然需要尽量避免笨重的I/O操作。

因此设计者针对读操作进行了简单的优化,当SharedPreferences对象第一次通过Context.getSharedPreferences()进行初始化时,对xml文件进行一次读取,并将文件内所有内容(即所有的键值对)缓到内存的一个Map中,这样,接下来所有的读操作,只需要从这个Map中取就可以了:

final class SharedPreferencesImpl implements SharedPreferences {
  private final File mFile;             // 对应的xml文件
  private Map<String, Object> mMap;     // Map中缓存了xml文件中所有的键值对
}

读者不禁会有疑问,虽然节省了I/O的操作,但另一个视角分析,当xml中数据量过大时,这种 内存缓存机制 是否会产生 高内存占用 的风险?

这也正是很多开发者诟病SharedPreferences的原因之一,那么,从事物的两面性上来看,高内存占用 真的是设计者的问题吗?

不尽然,因为SharedPreferences的设计初衷是数据的 轻量级存储 ,对于类似应用的简单的配置项(比如一个boolean或者int类型),即使很多也并不会对内存有过高的占用;而对于复杂的数据(比如复杂对象序列化后的字符串),开发者更应该使用类似Room这样的解决方案,而非一股脑存储到SharedPreferences中。

因此,相对于「SharedPreferences会导致内存使用过高」的说法,笔者更倾向于更客观的进行总结:

虽然 内存缓存机制 表面上看起来好像是一种 空间换时间 的权衡,实际上规避了短时间内频繁的I/O操作对性能产生的影响,而通过良好的代码规范,也能够避免该机制可能会导致内存占用过高的副作用,所以这种设计是 值得肯定 的。

3、写操作的优化

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

我们知道对键值对进行更新是通过mSharedPreferences.edit().putString().commit()进行操作的——edit()是什么,commit()又是什么,为什么不单纯的设计初mSharedPreferences.putString()这样的接口?

设计者希望,在复杂的业务中,有时候一次操作会导致多个键值对的更新,这时,与其多次更新文件,我们更倾向将这些更新 合并到一次写操作 中,以达到性能的优化。

因此,对于SharedPreferences的写操作,设计者抽象出了一个Editor类,不管某次操作通过若干次调用putXXX()方法,更新了几个xml中的键值对,只有调用了commit()方法,最终才会真正写入文件:

// 简单的业务,一次更新一个键值对
sharedPreferences.edit().putString().commit();


// 复杂的业务,一次更新多个键值对,仍然只进行一次IO操作(文件的写入)
Editor editor = sharedPreferences.edit();
editor.putString();
editor.putBoolean().putInt();
editor.commit();   // commit()才会更新文件

了解到这一点,读者应该明白,通过简单粗暴的封装,以达到类似SPUtils.putXXX()这种所谓代码量的节省,从而忽略了Editor.commit()的设计理念和使用场景,往往是不可取的,从设计上来讲,这甚至是一种 倒退 。

另外一个值得思考的角度是,本质上文件的I/O是一个非常重的操作,直接放在主线程中的commit()方法某些场景下会导致ANR(比如数据量过大),因此更合理的方式是应该将其放入子线程执行。

因此设计者还为Editor提供了一个apply()方法,用于异步执行文件数据的同步,并推荐开发者使用apply()而非commit()。

看起来Editor+apply()方法对写操作做了很大的优化,但更多的问题随之而来,比如子线程更新文件,必然会引发 线程安全问题;此外,apply()方法真的能够像我们预期的一样,能够避免ANR吗?答案是并不能,这个我们后文再提。

4、数据的更新 & 文件数量的权衡

随着业务复杂度的上升,需要面对新的问题是,xml文件中的数据量愈发庞大,一次文件的写操作成本也愈发高昂。

xml中数据是如何更新的?读者可以简单理解为 全量更新 ——通过上文,我们知道xml文件中的数据会缓存到内存的mMap中,每次在调用editor.putXXX()时,实际上会将新的数据存入在mMap,当调用commit()或apply()时,最终会将mMap的所有数据全量更新到xml文件里。

由此可见,xml中数据量的大小,的确会对 写操作 的成本有一定的影响,因此,设计者更建议将 不同业务模块的数据分文件存储 ,即根据业务将数据存放在不同的xml文件中。

因此,不同的xml文件应该对应不同的SharedPreferences对象,如果想要对某个xml文件进行操作,就通过传不同的文件标识符,获取对应的SharedPreferences:

@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
  // name参数就是文件名,通过不同文件名,获取指定的SharedPreferences对象
}

因此,当xml文件过大时,应该考虑根据业务,细分为若干个小的文件进行管理;但过多的小文件也会导致过多的SharedPreferences对象,不好管理且易混淆。实际开发中,开发者应根据业务的需要进行对应的平衡。

二、线程安全问题

SharedPreferences是线程安全的吗?

毫无疑问,SharedPreferences是线程安全的,但这只是对成品而言,对于我们目前的实现,显然还有一定的差距,如何保证线程安全呢?

——那,为了保证线程安全,怎么着不得加个锁吧。

加个锁?那是起步!3把锁,你还别嫌多。你得研究开发写代码时的心理,舍得往代码里吭哧吭哧加锁的开发,压根不在乎再加2把。

1、保证复杂流程代码的可读性

为了保证SharedPreferences是线程安全的,Google的设计者一共使用了3把锁:

final class SharedPreferencesImpl implements SharedPreferences {
  // 1、使用注释标记锁的顺序
  // Lock ordering rules:
  //  - acquire SharedPreferencesImpl.mLock before EditorImpl.mLock
  //  - acquire mWritingToDiskLock before EditorImpl.mLock


  // 2、通过注解标记持有的是哪把锁
  @GuardedBy("mLock")
  private Map<String, Object> mMap;


  @GuardedBy("mWritingToDiskLock")
  private long mDiskStateGeneration;


  public final class EditorImpl implements Editor {
    @GuardedBy("mEditorLock")
    private final Map<String, Object> mModified = new HashMap<>();
  }
}

对于这样复杂的类而言,如何提高代码的可读性?SharedPreferencesImpl做了一个很好的示范:通过注释明确写明加锁的顺序,并为被加锁的成员使用@GuardedBy注解。

对于简单的 读操作 而言,我们知道其原理是读取内存中mMap的值并返回,那么为了保证线程安全,只需要加一把锁保证mMap的线程安全即可:

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

那么,对于 写操作 而言,我们也能够通过一把锁达到线程安全的目的吗?

2、保证写操作的线程安全

对于写操作而言,每次putXXX()并不能立即更新在mMap中,这是理所当然的,如果开发者没有调用apply()方法,那么这些数据的更新理所当然应该被抛弃掉,但是如果直接更新在mMap中,那么数据就难以恢复。

因此,Editor本身也应该持有一个mEditorMap对象,用于存储数据的更新;只有当调用apply()时,才尝试将mEditorMap与mMap进行合并,以达到数据更新的目的。

因此,这里我们还需要另外一把锁保证mEditorMap的线程安全,笔者认为,不和mMap公用同一把锁的原因是,在apply()被调用之前,getXXX和putXXX理应是没有冲突的。

代码实现参考如下:

public final class EditorImpl implements Editor {
  @Override
  public Editor putString(String key, String value) {
      synchronized (mEditorLock) {
          mEditorMap.put(key, value);
          return this;
      }
  }
}

而当真正需要执行apply()进行写操作时,mEditorMap与mMap进行合并,这时必须通过2把锁保证mEditorMap与mMap的线程安全,保证mMap最终能够更新成功,最终向对应的xml文件中进行更新。

文件的更新理所当然也需要加一把锁:

// SharedPreferencesImpl.EditorImpl.enqueueDiskWrite()
synchronized (mWritingToDiskLock) {
    writeToFile(mcr, isFromSyncCommit);
}

最终,我们一共通过使用了3把锁,对整个写操作的线程安全进行了保证。

篇幅限制,本文不对源码进行详细引申,有兴趣的读者可参考 SharedPreferencesImpl.EditorImpl 类的apply()源码。

3、摆脱不掉的ANR

apply()方法设计的初衷是为了规避主线程的I/O操作导致ANR问题的产生,那么,ANR的问题真得到了有效的解决吗?

并没有,在 字节跳动技术团队 的 这篇文章(https://mp.weixin.qq.com/s/IFgXvPdiEYDs5cDriApkxQ)中,明确说明了线上环境中,相当一部分的ANR统计都来自于SharedPreference,由此可见,apply()并没有完全规避掉这个问题,那么导致ANR的原因又是什么呢。

经过我们的优化,SharedPreferences的确是线程安全的,apply()的内部实现也的确将I/O操作交给了子线程,可以说其本身是没有问题的,而其原因归根到底则是Android的另外一个机制。

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

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

什么情况下SharedPreferences会一直没有完成任务呢?比如太频繁无节制的apply(),导致任务过多,这也侧面说明了SPUtils.putXXX()这种粗暴的设计的弊端。

Google为何这么设计呢?字节跳动技术团队的这篇文章中做出了如下猜测:

无论是 commit 还是 apply 都会产生 ANR,但从 Android 之初到目前 Android8.0,Google 一直没有修复此 bug,我们贸然处理会产生什么问题呢。Google 在 Activity 和 Service 调用 onStop 之前阻塞主线程来处理 SP,我们能猜到的唯一原因是尽可能的保证数据的持久化。因为如果在运行过程中产生了 crash,也会导致 SP 未持久化,持久化本身是 IO 操作,也会失败。

如此看来,导致这种缺陷的原因,其设计也的确是有自身的考量的,好在字节跳动的这篇文章 末尾也提出了一个折衷的解决方案,有兴趣的读者可以了解一下,本文不赘述。

三、进程安全问题

1、如何保证进程安全

SharedPreferences是否进程安全呢?让我们打开SharedPreferences的源码,看一下最顶部类的注释:

/**
 * ...
 * This class does not support use across multiple processes.
 * ...
 */
public interface SharedPreferences {
  // ...
}

由此,由于没有使用跨进程的锁,SharedPreferences是进程不安全的,在跨进程频繁读写会有数据丢失的可能,这显然不符合我们的期望。

那么,如何保证SharedPreferences进程的安全呢?

实现思路很多,比如使用文件锁,保证每次只有一个进程在访问这个文件;或者对于Android开发而言,ContentProvider作为官方倡导的跨进程组件,其它进程通过定制的ContentProvider用于访问SharedPreferences,同样可以保证SharedPreferences的进程安全;等等。

2、文件损坏 & 备份机制

SharedPreferences再次迎来了新的挑战。

由于不可预知的原因(比如内核崩溃或者系统突然断电),xml文件的 写操作 异常中止,Android系统本身的文件系统虽然有很多保护措施,但依然会有数据丢失或者文件损坏的情况。

作为设计者,如何规避这样的问题呢?答案是对文件进行备份,SharedPreferences的写入操作正式执行之前,首先会对文件进行备份,将初始文件重命名为增加了一个.bak后缀的备份文件:

// 尝试写入文件
private void writeToFile(...) {
  if (!backupFileExists) {
      !mFile.renameTo(mBackupFile);
  }
}

这之后,尝试对文件进行写入操作,写入成功时,则将备份文件删除:

// 写入成功,立即删除存在的备份文件
// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();

反之,若因异常情况(比如进程被杀)导致写入失败,进程再次启动后,若发现存在备份文件,则将备份文件重名为源文件,原本未完成写入的文件就直接丢弃:

// 从磁盘初始化加载时执行
private void loadFromDisk() {
    synchronized (mLock) {
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }
  }

现在,通过文件备份机制,我们能够保证数据只会丢失最后的更新,而之前成功保存的数据依然能够有效。

 四、小结 

综合来看,SharedPreferences那些一直被关注的问题,从设计的角度来看,都是有其自身考量的。

我们可以看到,虽然SharedPreferences其整体是比较完善的,但是为什么相比较MMKV和Jetpack DataStore,其性能依然有明显的落差呢?

这个原因更加综合且复杂,即使笔者也还是处于浅显的了解层面,比如后两者在其数据序列化方面都选用了更先进的protobuf协议,MMKV自身的数据的 增量更新 机制等等,有机会的话会另起新的一篇进行分享。

反过头来,相对于对组件之间单纯进行 好 和 不好 的定义,笔者更认为通过辩证的方式去看待和学习它们,相信即使是SharedPreferences,学习下来依然能够有所收获。

只要你是程序员,在这里都能找到你想要的东西!
除此之外,还有每周送书活动!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值