
- SharedPreference是线程安全的吗?⭐⭐⭐
- SharedPreference的apply和commit的区别?commit 一定会在主线程操作嘛?⭐⭐⭐⭐⭐
- SharedPreferences 是如何初始化的,它会阻塞主线程吗?⭐⭐⭐
- 每次获取 SP 对象真的会很慢吗?⭐⭐⭐
- 在使用时需要注意哪些问题,以及有什么优化点呢?⭐⭐⭐⭐
SharedPreferences 是什么?怎么用?
- 数据格式:XML格式保存
- 初始化:子线程使用I0读取整个文件,进行XML解析,存入内存Map集合
- 保存:commit同步提交,阻塞主线程;apply异步提交,无法获取结果且可能数据丢失
- 更新:把Map中的数据,全部序列化为XML,覆盖文件保存。(全量更新)
Android的永久性存储方式有哪些:
- File
- SharedPreferences
- SQlite
- 网络
- ContentProvider
SharedPreferences是基于key-value 键值对生成的一个xml文件,保存在/data/data/packageName/shared_prefs目录下,适合保存少量数据,且数据格式相对简单。
1 | SharedPreferences sharedPreferences = context.getSharedPreferences(“xurui”, Context.MODE_PRIVATE); //1 |
SharedPreferences本身是一个接口,可以通过getSharedPreferences()获取实例,原型是:
1 | SharedPreferences getSharedPreferences(String name, int mode); |
其中name是最终生成的xml文件的文件名,mode代表不同的存储方式,查看源码有:
1 |
|
mode分成以下5种类型:
- MODE_PRIVATE:表示该 SharedPreferences数据只能被本应用读写,或者有相同用户ID的应用读写;
- MODE_WORLD_READABLE:全局读,允许所有其他应用程序对创建的文件具有读访问权限,该模式官方强烈建议不使用,因为会造成安全问题;
- MODE_WORLD_WRITEABLE:全局写,允许所有其他应用程序对创建的文件有写访问权限,官方同样建议不使用;
- MODE_MULTI_PROCESS:可以实现多进程访问SharedPreferences数据的问题,但是这种方式的多进程共享数据可能会出现数据不一致的问题,也不可靠,现在也不使用了;
- MODE_APPED:该模式不在规定的PreferencesMode模式里,但也可以使用,如果文件存在且对应的key也存在,则可以在对应的value数值追加新的内容,不同于MODE_PRIVATE,MODE_PRIVATE会把旧的内容覆盖掉。
接着,获取到的SharedPreferences实例本身仅支持获取数据,如果需要修改或者储存,需要通过SharedPreferences.Editor来实现,同样Editor也是接口,可以通过sharedPreferences.edit()获取实例,并通过putString修改内容,最后执行apply()即可(apply()下面会分析)。如此我们就完成了一对key-value 键值对的存放,到/data/data/packageName/shared_prefs下就可以找到一个叫“xurui.xml”的文件,里面就存放着刚添加的键值对。
源码分析
获取SharedPreferencesImpl实例
context.getSharedPreferences(“xurui”, Context.MODE_PRIVATE):/frameworks/base/core/java/android/app/ContextImpl.java
1 |
|
上面总共有3个函数,环环相扣,context.getSharedPreferences(“xurui”, Context.MODE_PRIVATE)
对应注释6.如上所述,这里“xurui”是指一个xml文件的名字,注释7的mSharedPrefsPaths是一个Map数据,其中key对应文件名称,value则是对应的xml文件。如果输入的文件名存在,则获取出对应文件,如注释8.如果文件不存在则创建并保存,对应注释9.最后获取到的文件传入注释10,也就是上面的第2个函数。
进入函数2,看看注释11,这一行需要重点理,其中:
- File:xml文件名;
- SharedPreferencesImpl:xml文件名在内存对应的实例,源码并非简单的对xml文件进行读写,而是每个文件会创建专门的实例来执行各种操作和判断;
getSharedPreferencesCacheLocked()做了什么?
首先会创建sSharedPrefsCache,这也是一个Map,其key是包名,value是packagePrefs,对应注释14,其中:
- sSharedPrefsCache:定义如下,也是在ContextImpl.java中定义,因为一个进程只有一个ContextImpl对象,一个ContextImp只定义一个sSharedPrefsCache,所以一个进程也就只有一个sSharedPrefsCache;
1 | private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache; |
- packageName:一个进程只有一个sSharedPrefsCache,但是可能有多个应用,因此需要用包名做区分;
- SharedPreferencesImpl:xml文件名在内存对应的实例,源码并非简单的对xml文件进行读写,而是每个文件会创建专门的实例来执行各种操作和判断;
- packagePrefs: SharedPreferences 文件与 SharedPreferences 实例对象之间的映射关系;
如果getSharedPreferencesCacheLocked()中找不到输入文件名对应的SharedPreferencesImpl实例,则在注释12根据文件名和模式,新创建一个实例并返回。
过程
首先sSharedPrefsCache会保存从磁盘加载到内存的 SharedPreferences 对象,该对象保存所在进程所有应用程序的SharedPreferences 对象,通过当前进程的包名获取对应的cache,对应注释11。获取到后,又因为一个应用程序可能有多个SharedPreferences的xml文件,可通过传入文件名获取到最终的SharedPreferencesImpl实例,对应注释10.
同时有一点需要注意,ContextImpl 类中没有定义将 SharedPreferences 对象移除 sSharedPrefsCache 的方法,因此sSharedPrefsCache一旦从磁盘加载到内存后,直到进程销毁之前都会保存在内存,因此SP对象的使用都是在内存中使用,而不会第二次出现从磁盘读取。
SharedPreferencesImpl实例分析
拿到SharedPreferencesImpl实例后frameworks/base/core/java/android/app/SharedPreferencesImpl.java
1 | SharedPreferencesImpl(File file, int mode) { |
通过文件名获取该文件对应的SharedPreferencesImpl实例,此时从构造函数执行到[注释15]后会创建一个子线程开始读取磁盘中的xml文件,也就是[注释17]的loadFromDisk(),此时就会在子线程里读取磁盘里的xml文件转换为内存中的SharedPreferencesImpl实例。此时迎来一个关键问题:
SharedPreferences实例初始化过程虽然在子线程执行,但真的不会对主线程造成阻塞吗?
既然这么问了,想必就是子线程中的执行SharedPreferences实例初始化过程确实可能对主线程造成阻塞了。先注意[注释16]的mLoaded = false,然后一开始就有说SharedPreferences修改或者储存,需要通过SharedPreferences.Editor来实现,看看Editor的源码:
1 |
|
因此,如果我们在主线程使用SP存储一个数据时:
1 | SharedPreferences.Editor editor = sharedPreferences.edit(); //22 |
执行到[注释22]时最终会执行到[注释20],此时有两种情况:
- mLoader = true:此时正常返回EditorImpl,可正常存储数据;
- mLoader = false:此时会执行[注释21]的wait()开始阻塞,只有[注释17]的loadFromDisk()在子线程中完成从磁盘加载完数据到内存之后,mLoaded 才会被置为 true,对应[注释18],并最终执行到[注释19],重新唤醒edit()方法。
因此,虽然 SharedPreferences实例初始化过程虽然在子线程执行,此时自然不会阻塞主线程,但是如果初始化过程还没完成,主线程就要开始修改或者读取某个SharedPreferences的数值时,主线程就会被阻塞!!直到子线程中加载好文件。
读写数据
读
1 | SharedPreferencesImpl(File file,int mode){ |
startLoadFromDisk
中启动子线程执行IO操作(耗时)loadFromDisk()
方法中同样使用IO线程,加载FileInputStream再包装成BufferedInputstream,解析成xml格式保存在map中
1 | str = new BufferedInputstream( |
最终通过map读取数据
1 |
|
写
每次编辑都会创建一个新的EditorImpl
并且进行一次IO操作,严重影响性能,需要避免频繁的调用。
如果在一个循环里,或者在onDraw
中调用,会导致程序出现卡顿
创建EditorImpl
过程中如果没有发现对象逃逸,会导致gc频繁的运行,最终导致卡顿
如果是标记清除算法会导致OOM
1 |
|
存入到临时变量mModifiéd
,通过apply
/commit
写入到文件实现持久化
1 | @0verride |
commit和apply
上面我们执行了editor.putString(“key”, “value”).apply(),也就是设置好数值后,需要执行apply()才生效,那么除了apply()还有commit()/frameworks/base/core/java/android/app/SharedPreferencesImpl.java
1 | public void apply() { |
对比两个函数,都有相同的[注释23]commitToMemory()和[注释24]enqueueDiskWrite(),前者从命名就可以知道是将数据提交到内存里,然后再通过后者将需要写入磁盘的任务进行排队。看看后者的源码:
1 | private void enqueueDiskWrite(final MemoryCommitResult mcr, |
从[注释26]知道第二个参数如果为 null 就说明来自 commit,如果非空就说明来自 apply。然后再[注释27]创建一个writeToDiskRunnable将数据从内存写入磁盘,并在[注释30]执行,从代码可以知道只有在[注释28]isFromSyncCommit为true时,也就是此时是该函数是来自commit。以及[注释29]mDiskWritesInFlight == 1。那么mDiskWritesInFlight何时为1呢?
通过阅读源码知道,当apply()和commit()执行了commitToMemory()时,mDiskWritesInFlight会加1,代表写入内存的数据数量加1,当每次创建一个writeToDiskRunnable时,mDiskWritesInFlight会减1。 因此mDiskWritesInFlight为1就代表前面的提交到内存的修改都已经提交的磁盘上了。此时如果是来自commit,写磁盘任务就直接在当前线程即主线程里执行了,对应[注释30]。然而如果此时是来自commit,但mDiskWritesInFlight不等于1,则执行[注释31]和 apply 一样添加到 QueueWork 里,其实就是异步执行了。
这时候可以发现commit()方法如果执行[注释30]是在主线程执行的,如果执行[注释31]则是在子线程执行的。为何commit不全部都在主线程执行呢?
这是因为如果commit在主线程执行,apply在子线程执行,那么当执行了apply后马上执行commit,就可能会导致apply 的数据在 commit 之后被写入到磁盘中,磁盘中的数据是错误的,而且和内存中的数据不一致。而当两者在同一个线程中执行时,会通过[注释25] mcr.writtenToDiskLatch.await()来保持同步。
还有最后一点要注意,apply没有返回值,但commit返回boolean表明修改是否提交成功。最后汇总下两者的区别:
- apply没有返回值,commit有返回值(Boolean):需要确保提交成功且有后续操作的话,建议用commit;
- apply是异步提交到磁盘,commit是同步提交到磁盘,因此commit效率比较低(数据量大的时候会出现ANR),如果不需要返回值,或者希望效率高,建议使用apply。
- commit不一定在主线程执行;
- 会把一次提交包装成Runnable(任务),将这个任务放到QueueWork(队列)中,在QueueWork的子线程中排队执行
- 涉及到Activity之间的跳转,以AB两个Activity为例,从A跳转到B经过的生命周期是A:onPause B:onCreate onStart onResume A:onStop
- 在
handlePauseActivity
中会执行QueuedWork.waitToFinish();
方法。也就是说如果QueueWork中排队执行的任务没有执行完,这个waitToFinish
会一直等待直到执行结束。即使apply是异步执行的,但是在发生Activity的跳转时会在主线程等待才能执行后面的操作,所以也有可能导致ANR,导致未执行完的任务数据会丢失
生命周期怎么被调起的? / Activity启动流程
AMS通过Binder机制,通知到ActivityThread#ApplicationThread
ApplicationThread中对应的方法通过handler发message调起Activity
总结
在主线程中调用apply方法,在子线程中直接调用commit方法。
一个进程只有一个sSharedPrefsCache,但是可能有多个应用,因此需要用包名做区分;一个应用有可能有多个SP的xml文件,因此需要用文件名做区分。最后得到某个SP的xml文件在内存中的实例SharedPreferencesImpl。该实例一旦加载到内存后就一直保存在内存,直到这个进程销毁。但也因此如此,在SharedPreferencesImpl初始化的过程中会产生大量的临时对象,导致频繁GC,引起界面卡顿,同时占用大量内存。好处就是每次获取SP对象都很快,只是文件太大的话,加载时就比较慢,因此大量数据时不建议用SP,有助于减少卡顿 / ANR。JSON或者HTML也不建议用SP,应该直接用JSON。
SharedPreferences 和 Editor 都只是接口,真正的实现在 SharedPreferencesImpl 和 EditorImpl ,SharedPreferences 只能读数据,它是在内存中进行的,Editor 则负责存数据和修改数据,分为内存操作和磁盘操作。且不要高频调用edit(),而是每次执行edit()后多次执行putXXX;
SharedPreferencesImpl 实例化过程会启动子线程从磁盘读取xml文件内容,读取时因是在子线程运行,所以不会阻塞主线程,但如果此时没读取完就开始获取或者修改数值,就会引起主线程阻塞;
修改完内容后,在提交的时候分为 commit 和 apply,两者都会先把修改保存到内存,只不过 apply 是异步写磁盘,而 commit 可能是同步写磁盘也可能是异步写磁盘;不要每次putString后都apply,而是批量修改后一起提交;
SP 的读写操作是线程安全的,内部用了很多synchronized锁来实现线程同步。但进程不安全。文章一开始说的MODE_MULTI_PROCESS模式,也只是保证API 11以前的老系统上,每次获取这sharedPreference都会重新读一遍文件罢了。所以Google官方推荐使用ContentProvider来实现SharedPreference的进程同步,ContentProvider中有一个Bundle call(String method, String arg, Bundle extras)方法,我们可以重写该方法,根据传入的不同参数实现对sp的增删改查。
ANR 容易发生的地方:
修改或者获取数据时,执行edit()会调用 awaitLoadedLocked ,等待首次SP文件创建与读取操作完成;
apply 虽然是异步的但是可能会在 Service Activity 等生命周期期间mcr.writtenToDiskLatch.await() 等待过久
commit 最终会调用writeToFile(),该方法是磁盘操作,如果数据量太大会很耗时;
如果主线程直接getSharedPreferences(),此时如果SP文件很大,那么自然也会很耗时;
所以SP用于轻量数据存储操作,就非常的合适。
多进程方案
ContentProvider
使用ContentProvider进行跨进程通信,把其它进程的读写sp都统一到某个进程上。
MMKV
使用共享内存进行读写sp。
优化
比XML更精简的数据格式
XML繁琐,冗余信息多,数据量大,占用空间多
高效的文件操作
sp使用传统的方式:子线程+IO的方式
其他的方式:NIO(零拷贝技术):FileChannel.transformTO更优的数据更新方式
局部更新