Android SharedPreferences最佳实践

4,559 阅读8分钟

       Android开发中,我们经常会用到SharedPreferences,它是一种轻量的数据存储方式,通常用来存储一些简单的配置信息。看了网络上的一些文章,感觉都不是特别满意,因此希望能结合自己的经验和理解写一篇分析SharedPreferences的文章。本文不会讲解SharedPreferences的基本用法,而是会结合源码来分析SharedPreferences的工作原理,以及使用中存在的一些问题。

       通过这篇文章,你可以了解到:

  • SharedPreferences是怎么工作的

  • SharedPreferences使用中有哪些坑

  • 怎么来避免SharedPreferences的那些问题

      首先,我们要搞清楚SharedPreferences的本质是什么。它的本质是基于xml文件存储的key-value键值对数据,其存储位置在/data/data/包名/shared_prefs目录下。由于它是存储在应用程序的私有目录下,外部是无法直接访问的。也就是说它实际上就是一个xml文件,和普通的xml没有本质区别,内容也和我们工程代码里的strings.xml文件的内容类似。

源码分析

      下面我们通过对源码的分析,讲解一下它的工作原理。先来看一下SharePreferences的基本用法。

SharedPreferences sp = context.getSharedPreferences(“file1”, Context.MODE_PRIVATE);

sp.edit().putBoolean(“key1”, false).commit();

sp.getBoolean(“key1”)

      那么我们就从getSharedPreferences()方法开始讲起,实际上Context最终调用的是ContextImpl中的getSharedPreferences方法,我们看下这个方法。

      其中包含一个mSharedPrefsPaths对象,它是ArrayMap类型,我们可以在App中创建多个sp文件,mSharedPrefsPaths中就是存储了不同sp文件名和sp文件的对应关系。这里的getSharedPreferencesPath方法实际上就是在磁盘上创建了一个xml文件。查看上图最后一行的getSharedPreferences方法。

      我们看到这个方法实际返回了一个SharedPreferencesImpl对象,看下SharedPreferencesImpl的构造方法。

      其中调用了startLoadFromDisk方法,startLoadFromDisk在子线程里调用了loadFromDisk,执行线程之前将mLoaded设置为false,再来看下这个loadFromDisk方法。

      这个方法的代码很多,我们只看最核心的部分,它通过XmlUtils.readMapXml()将文件读取到mMap中,mMap是一个HashMap,并且将mLoaded设置为true,大家记住mLoaded这个变量,后面还会遇到它。也就是说,sp文件的内容被读取到内存并且缓存到mMap中了,后续对sp的操作都与内存中的缓存有关。既然sp文件的内容会缓存到内存中,如果文件中存储了大量数据,就会占用很大的内存空间,这点需要特别注意。

      SharedPreferences的创建过程讲完了,下面我们来看一下put过程。put操作首先要调用edit()方法,

      又见到了mLoaded这个变量,我们回忆一下之前的逻辑,在开始开启线程读取sp文件到内存的时候,这个变量被置为false,等线程执行完会置为true,在上图的awaitLoadedLocked方法中,如果发现mLoaded为false,则调用wait方法,此时会阻塞当前线程,直到sp文件读取完成,才调用notifyAll()通知这里被阻塞的线程继续执行。也就是说,如果读取sp文件的操作执行时间很长的话,这里就可能会阻塞主线程导致ANR。

      怎么才能尽可能的避免这个问题呢?首先,我们需要将sp文件根据功能和特点分解为多个小文件,比如根据不同的功能模块进行划分,或者根据读写的频率,也可以根据是否App启动的时候就需要加载。如果每个文件足够小,那么在读取文件到内存的时候,耗时自然也就少了。尤其是在App启动的时候,只需要加载启动时需要的sp配置,可以一定程度上减少启动时间。

      下面继续看源码。edit()方法返回了一个Editor对象,实际的类型是EditorImpl。

      EditorImpl中包含一个HashMap类型成员mModified,调用Editor的方法如putString之后,都只是将数据存储在mModified中。这里只是数据的暂存区,因此如果忘记调用commit或者apply方法,数据其实并没有写入磁盘。有一点需要注意的是我们每次调用edit方法,都会创建一个mModified对象,因此,有必要减少edit方法的调用。

      最后,就是调用commit或者apply方法了。我们知道commit是同步写入,会返回执行结果;而apply方法是异步写入,并不会返回执行结果。下面通过源码来分析下它们的实现。

      commit方法中先后调用了commitToMemory和enqueueDiskWrite。commitToMemory方法的作用是将前面提到的mModified中缓存的数据更新到前面提到的mMap中,这个mMap会被最终写入文件。我们看enqueueDiskWrite方法,它的第二个参数传了null,因此,isFromSyncCommit为true,然后直接执行了writeToDiskRunnable.run()方法,其中通过调用writeToFile将mMap中的配置内容写入sp文件。

      从源码中我们可以看出,commit的执行是同步的,而且是全量的写入。如果不是必要的情况,尽量不要使用commit去保存sp的配置,以防止写文件阻塞主线程。

      我们再来看apply方法的实现有什么不同。

      这里所不同的是enqueueDiskWrite的第二个参数不为null,所以方法内部将写入文件的操作放入了单线程的线程池异步执行:

      QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable)。

      由于是单线程,来不及执行的Runnable都被放在队列中等待执行 。writeToDiskRunnable里面执行了writeToFile将sp写入文件,然后调用了postWriteRunnable的run()方法,这里面又调用了awaitCommit的run()方法,最后调用了mcr.writtenToDiskLatch.await()。那么这个writtenToDiskLatch又有什么作用呢?通过代码,我们发现writeToFile方法里面最终会调用writtenToDiskLatch的countDown方法,也就是说,如果sp文件的写入一直没有执行完,writtenToDiskLatch.await()这个调用就会阻塞在这里,但从实际的执行时序上来看writtenToDiskLatch的countDown的调用又肯定是在await之前的,那么这个await的调用到底有什么作用呢?我们又注意到,这里有一行代码:QueuedWork.add(awaitCommit)。 我们看下这个QueuedWork是什么?

      图中略去了部分代码,add方法实际上就是将runnable加入到一个ConcurrentLinkedQueue中。下面的waitToFinish方法里会去遍历queue中的每个Runnable,并执行它的run方法。那么waitToFinish方法又是在哪里调用的呢?我们根据注释找到了ActivityThread类的handlePauseActivity、handleStopActivity方法,我们来看其中的一个。

      我们看到,在Activity调用onStop的时候,会调用QueuedWork.waitToFinish(),遍历执行其中的runnable。假设我们频繁的调用了apply方法,并紧接着调用了onStop,那么就可能会发生onStop一直等待QueuedWork.waitToFinish执行完成而产生ANR。也就是说,即使是调用了apply方法去异步提交,也不是完全安全的。如果apply方法使用不当,也许会遇到与下图类似的问题。

      上面讲了put操作,由于get操作相对简单一些,这里就不单独分析了。

总结

      从上面的分析我们发现SharedPreferences的使用并不是那么简单的,使用不当可能会导致程序异常,我们对上面提到的一些问题进行一下总结:

  • sp配置不要全部都写在一个文件中,这样不仅第一次加载会很慢,也会占用大量内存。最好是根据一定规则分成多个sp文件。比如频繁和不频繁写入的配置就分别存储在两个不同的文件中。

  • sp文件的写入是全量写入,即使改了一条配置,写入的时候也会对整个文件进行操作,因此最好能批量操作,不要每次都commit。

  • 启动的时候需要读取sp的配置最好异步进行,如果一定要同步读取,启动的sp文件要尽可能的小。

  • 不要将太大的配置项(包括key和value)存储在sp中,否则会占用大量内存。

  • 获取SharedPreferences对象的时候会读取sp文件,如果文件没有读取完,就执行了get和put操作,可能会出现需要等待的情况,因此最好提前获取SharedPreferences对象。

  • 每次调用edit方法都会创建一个新的EditorImpl对象,不要频繁调用edit方法。

  • apply方法虽然是在线程中异步将配置写入文件,但是如果任务很多,而且每个任务执行时间很长,也可能会导致Activity或Service在stop的时候出现ANR。

欢迎关注我的微信公众号,收到最新的推送文章