一、 本節目標
SharedPreference 是一個輕量級的 key-value
存儲框架。開發者很容易地可以使用它的 api ,但是如果不恰當的使用可能會導致一些問題,所以針對如何使用和處理這些問題,列出了以下幾個小點。
1、sp 實例的獲取。
2、sp 是如何進行讀寫操作和緩存處理的?
3、commit 和 apply 的區別?
4、不恰當使用 sp 的一些坑。
5、sp 中幾種模式的選擇。
我們這篇博客主要就是來學習一下上面所提到的幾個點,并從源碼的角度來分析各個問題。
二、sp 實例的獲取
2.1、 初始化
SharedPreference
是一個接口,它的實現類是 SharedPreferenceImpl
。有三種方式可以來獲取:
- 方式一 Activity#getPreferences
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
return mBase.getSharedPreferences(name, mode);
}
- 方式二 PreferenceManager#getDefaultSharedPreferences
public static SharedPreferences getDefaultSharedPreferences(Context context) {
return context.getSharedPreferences(getDefaultSharedPreferencesName(context),
getDefaultSharedPreferencesMode());
}
- 方式三 ContextImpl#getSharedPreferences
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
...
}
通過源碼可以知道,最終都是用調用第三種方式來實例化
SharedPreferenceImpl
這個實例的。
2.2、 getSharedPreferences源碼分析
這里使用了一個集合來緩存實例化后的 SharedPreferenceImpl 實例,這樣下次調用該方法時,就直接從內存中獲取這個對象。
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
...
SharedPreferencesImpl sp;
//加鎖,線程安全。
synchronized (ContextImpl.class) {
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
//1. 從緩存存取 SharedPreferencesImpl 實例
sp = cache.get(file);
if (sp == null) {
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
return sp;
}
2.3、 SharedPreferencesImpl 的構造
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
startLoadFromDisk();
}
從構造方法中可以看出
mFile 就是當前 SharedPreferencesImpl 所映射的文件,所有的 key-value 的數據都會保存到這個文件中。
mBackupFile 可以理解為是一個備份文件,當保存數據失敗等操作,可以從這個文件進行恢復數據。
mMode 表示文件的模式。
mMap 用于存儲 key-value 鍵值對信息。
mLoaded 理解這個變量之前,首先要知道,sp 使用來存儲 key-value 的,這些鍵值對數據在內存中是保存到一個 mMap 集合中,對應寫入的磁盤文件就是 mFile 。這個變量就是標記 mFile 文件的數據是否已經加載到 mMap 集合中。具體的使用,下面會描述。
startLoadFromDisk() 內部開啟線程去從文件中加載數據到 mMap 集合中。具體的使用,下面會描述。
2.4、 startLoadFromDisk 加載數據
在 2.3 中可以知道, mMap 是用于存儲鍵值對的集合,而 mFile 是最終保存的本地文件。startLoadFromDisk 就是從 mFile 本地文件中去加載數據到 mMap 集合中,下面我們來看看源碼是如何實現的。
private void startLoadFromDisk() {
// mLoaded 標記為 false
synchronized (this) {
mLoaded = false;
}
//開啟線程,讀取磁盤數據
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
loadFromDisk 在子線程讀取磁盤數據,源碼如下:
private void loadFromDisk() {
synchronized (SharedPreferencesImpl.this) {
//mLoaded 避免重復讀取
if (mLoaded) {
return;
}
//如果備份文件存在,使用備份文件,回滾數據。
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
Map map = null;
StructStat stat = null;
try {
stat = Os.stat(mFile.getPath());
if (mFile.canRead()) {
BufferedInputStream str = null;
try {
//讀取數據,保存到 map 對象中
str = new BufferedInputStream(
new FileInputStream(mFile), 16*1024);
map = XmlUtils.readMapXml(str);
} catch (XmlPullParserException | IOException e) {
Log.w(TAG, "getSharedPreferences", e);
} finally {
IoUtils.closeQuietly(str);
}
}
} catch (ErrnoException e) {
/* ignore */
}
synchronized (SharedPreferencesImpl.this) {
//標記讀取成功
mLoaded = true;
if (map != null) {
//給成員變量賦值
mMap = map;
mStatTimestamp = stat.st_mtime;
mStatSize = stat.st_size;
} else {
//首次可能文件為空,所以在這里 new 一個對象。
mMap = new HashMap<>();
}
//讀取完畢,激活其他線程。
notifyAll();
}
}
以上兩個方法概括起來主要做了以下幾件事:
1、
mLoaded
標記的設置,開始讀取數據時,將其設置為 false,表示還未加載完成,讀取成功之后標記為true
,表示加載完成。2、
mBackupFile
首先判斷備份文件是否存在,存在則優先從備份文件中恢復數據。3、 按照指定的格式讀取文件數據,保存到
mMap
集合中。4、
激活
其它阻塞的線程。具體如何使用,下面會介紹。
三、 sp 對集合存取的操作
3.1、 從集合中取出數據
public int getInt(String key, int defValue) {
synchronized (this) {
//判斷 mLoaded 數據是否為 true,不是則 wait 等待數據加載線程執行完畢。
awaitLoadedLocked();
//從 mMap 集合中獲取數據。
Integer v = (Integer)mMap.get(key);
return v != null ? v : defValue;
}
}
get
操作都是加了synchronized
的,因此它是線程安全的。在獲取數據之前首先awaitLoadedLocked()
方法,判斷數據是否加載成功。 我們知道如果調用getSharedPreference()
方法之后很快又去調用 getXxx() 方法,那么可能此時線程還在加載加載文件中的數據到內存中,這時的mLoaded
為false
,那么awaitLoadedLocked();
這個方法就會使當前線程阻塞直到數據加載成功,這時會notifyAll()
這樣就可以正常的讀取數據了。
3.2、 存入數據到集合中
存取一個整型數據:
context.getSharedPreference
.edit()
.putInt("key",value)
.commit();
這里會涉及到一個 Editor
的對象,它也是一個接口,它的實現類是 EditorImpl
而 putInt,putBoolean,putLong 等操作都是通過 Editor
實現的。我們可以通過 getSharedPreference.edit()
就可以獲取 Editor 的實例對象。
而對 EdiatorImpl 的一系列的 putXxx() 和 getXxx() 就是在操作 mModified 集合,而最后通過調用 commit()
或者apply()
就會將數據寫入 mMap 和對應的 mFile 中。
public final class EditorImpl implements Editor {
private final Map<String, Object> mModified = Maps.newHashMap();
...
public Editor putInt(String key, int value) { ... }
public boolean commit() { ... }
public boolean apply() { ... }
...
}
四、 保存數據 commit與 apply 的區別
4.1、 異步式 apply 保存數據
public void apply() {
//1.保存數據到mMap內存中,并返回一個MemoryCommitResult 對象
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
//3.阻塞調用者
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
//2.往 QueuedWork 添加一個任務。
QueuedWork.add(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
public void run() {
//從 QueuedWork 中移除
awaitCommit.run();
QueuedWork.remove(awaitCommit);
}
};
//4.執行寫入的任務。
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
notifyListeners(mcr);
}
通過上面的apply()
源碼可以看出大致流程是
1、 將數據寫入到內存 mMap 中,并返回一個 MemoryCommitResult 對象,它封裝要寫入的數據到文件中的。
2、 QueuedWork.add(awaitCommit);往 QueuedWork 添加一個任務。
3、 當
awaitCommit
被調用時,那么當前線程會被阻塞,直到postWriteRunnable
這個任務執行,才將awaitCommit
從QueuedWork中移除。4、 執行寫入的任務。
4.2、 commitToMemory()
簡單描述就是使用
MemoryCommitResult
來封裝需要寫入到文件的數據 mMap 和使用 changesMade 標記當前這次 apply 或者 commit 操作是否有新的數據要提交。
// Returns true if any changes were made
private MemoryCommitResult commitToMemory() {
MemoryCommitResult mcr = new MemoryCommitResult();
synchronized (SharedPreferencesImpl.this) {
// We optimistically don't make a deep copy until
// a memory commit comes in when we're already
// writing to disk.
if (mDiskWritesInFlight > 0) {
// We can't modify our mMap as a currently
// in-flight write owns it. Clone it before
// modifying it.
// noinspection unchecked
mMap = new HashMap<String, Object>(mMap);
}
//存儲要寫入到文件的數據
mcr.mapToWriteToDisk = mMap;
mDiskWritesInFlight++;
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
mcr.keysModified = new ArrayList<String>();
mcr.listeners =
new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (this) {
if (mClear) {
if (!mMap.isEmpty()) {
mcr.changesMade = true;
mMap.clear();
}
mClear = false;
}
//遍歷 mModified 將數據保存到 mMap 集合中。
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 (!mMap.containsKey(k)) {
continue;
}
mMap.remove(k);
} else {
if (mMap.containsKey(k)) {
Object existingValue = mMap.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mMap.put(k, v);
}
//標記是有讀寫操作。
mcr.changesMade = true;
if (hasListeners) {
mcr.keysModified.add(k);
}
}
mModified.clear();
}
}
return mcr;
}
4.3、 enqueueDiskWrite(...)
這個方法主要是創建一個任務
writeToDiskRunnable
并且交給線程池去執行。在這個任務中是負責將mMap
中的數據寫入到文件中,并且在寫入完成后,調用postWriteRunnable.run();
來執行寫入任務完畢后的后續操作,例如從QueueWork
移除任務等。
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final Runnable writeToDiskRunnable = new Runnable() {
public void run() {
synchronized (mWritingToDiskLock) {
//寫入文件的任務
writeToFile(mcr);
}
synchronized (SharedPreferencesImpl.this) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
//寫入任務執行完畢后的后續操作,例如從 QueueWork移除任務等。
postWriteRunnable.run();
}
}
};
...
//在線程池中執行寫入任務。
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
4.4、writeToFile(mcr)寫入數據到文件中
在這個方法中真正去執行寫入操作,我大致關心的是整體流程,因此具體的寫操作就不必要深究了。我們這里處理寫的操作外,還有一個方法需要注意的是,那就是
mcr.setDiskWriteResult(...)
,這個方法內部會調用writtenToDiskLatch.countDown();
遞減等待線程的數量。
private void writeToFile(MemoryCommitResult mcr) {
// Rename the current file so it may be used as a backup during the next read
if (mFile.exists()) {
//當前沒有要寫入的操作
if (!mcr.changesMade) {
//標記寫入完成
mcr.setDiskWriteResult(true);
return;
}
...
}
// Attempt to write the file, delete the backup and return true as atomically as
// possible. If any exception occurs, delete the new file; next time we will restore
// from the backup.
try {
FileOutputStream str = createFileOutputStream(mFile);
if (str == null) {
mcr.setDiskWriteResult(false);
return;
}
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
FileUtils.sync(str);
str.close();
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
try {
final StructStat stat = Os.stat(mFile.getPath());
synchronized (this) {
mStatTimestamp = stat.st_mtime;
mStatSize = stat.st_size;
}
} catch (ErrnoException e) {
// Do nothing
}
// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();
//標記寫入成功
mcr.setDiskWriteResult(true);
return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}
// Clean up an unsuccessfully written file
if (mFile.exists()) {
if (!mFile.delete()) {
Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
}
}
//標記寫入失敗
mcr.setDiskWriteResult(false);
}
public void setDiskWriteResult(boolean result) {
writeToDiskResult = result;
//遞減等待線程
writtenToDiskLatch.countDown();
}
4.4、setDiskWriteResult的內部實現
我們知道在 4.1 中有如下代碼,當時并沒有解釋 mcr.writtenToDiskLatch.await();
這段代碼的作用。我們知道 QueueWork 只要執行 awaitCommit
這個任務,那么當前的線程會被阻塞,直到寫入任務完成。
//1.保存數據到mMap內存中,并返回一個MemoryCommitResult 對象
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
//3.阻塞調用者
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
Runnable postWriteRunnable = new Runnable() {
public void run() {
//從 QueuedWork 中移除
awaitCommit.run();
QueuedWork.remove(awaitCommit);
}
};
setDiskWriteResult
這個方法是在writeToFile
中調用的,它內部會調用 writtenToDiskLatch.countDown();
遞減等待線程的數量,此時的等待線程就是寫入數據的線程。當等待線程的數量遞減到數量為 0
時也就是writeToFile方法執行完畢,那么這 postWriteRunnable
任務被調用時,這時就可以將 awaitCommit
從 QueueWork 內部的隊列中移除。
public void setDiskWriteResult(boolean result) {
writeToDiskResult = result;
//遞減等待線程
writtenToDiskLatch.countDown();
}
4.5、阻塞式commit() 提交數據
看完 apply 那么現在來看 commit 應該就很容明白了, commit 是阻塞式的,執行完畢之后會返回一個 boolean 變量值表示數據是否寫入成功。
public boolean commit() {
//保存到內存中
MemoryCommitResult mcr = commitToMemory();
//寫入數據
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
//阻塞等待寫入線程完畢
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
五、 不恰當使用 sp 的一些坑
5.1、 示例1
在這里先看一個 ANR 的日志信息。
"main" prio=5 tid=1 WAIT
| group="main" sCount=1 dsCount=0 obj=0x4155cc90 self=0x41496408
| sysTid=13523 nice=0 sched=0/0 cgrp=apps handle=1074110804
| state=S schedstat=( 2098661082 1582204811 6433 ) utm=165 stm=44 core=0
at java.lang.Object.wait(Native Method)
- waiting on <0x4155cd60> (a java.lang.VMThread) held by tid=1 (main)
at java.lang.Thread.parkFor(Thread.java:1205)
at sun.misc.Unsafe.park(Unsafe.java:325)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:157)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:813)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedInterruptibly(AbstractQueuedSynchronizer.java:973)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(AbstractQueuedSynchronizer.java:1281)
at java.util.concurrent.CountDownLatch.await(CountDownLatch.java:202)
at android.app.SharedPreferencesImpl$EditorImpl$1.run(SharedPreferencesImpl.java:364)
at android.app.QueuedWork.waitToFinish(QueuedWork.java:88)
at android.app.ActivityThread.handleServiceArgs(ActivityThread.java:2689)
at android.app.ActivityThread.access$2000(ActivityThread.java:135)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1494)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:137)
at android.app.ActivityThread.main(ActivityThread.java:4998)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:515)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:777)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:593)
at dalvik.system.NativeStart.main(Native Method)
在上面的日志中,我們很明顯可以看到一個關鍵字await
,我們猜應該是線程鎖的問題。代碼定位到 QueuedWork.waitToFinish
中。
從 waitToFinish 方法中可以知道它會在Activity暫停時,BroadcastReceiver的onReceive方法調用后或者service的命令處理后被調用,并且調用這個方法的目的是為了確保異步任務被及時完成。
/**
* Finishes or waits for async operations to complete.
* (e.g. SharedPreferences$Editor#startCommit writes)
*
* Is called from the Activity base class's onPause(), after
* BroadcastReceiver's onReceive, after Service command handling,
* etc. (so async work is never lost)
*/
public static void waitToFinish() {
Runnable toFinish;
while ((toFinish = sPendingWorkFinishers.poll()) != null) {
toFinish.run();
}
}
waitToFinish()
這個方法內部是去遍歷 QueueWork
的隊列 sPendingWorkFinishers
并且執行對應的任務,而回到4.1 節
中,我們創建了一個 awaitCommit
并且添加到 QueueWork
隊列中,如果此時 waitToFinish()
被執行時,而這時 mcr. writtenToDiskLatch
中的等待線程數量沒有遞減到 0
,也就是此時 commit 和 apply 的寫入文件操作還在進行,那么waitToFinish
的調用線程
就會被阻塞
,從而導致 ANR
的問題。
//1.保存數據到mMap內存中,并返回一個MemoryCommitResult 對象
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
//3.阻塞調用者
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
//2.往 QueuedWork 添加一個任務。
QueuedWork.add(awaitCommit);
5.2、 示例2
ANR 的第二個場景是,在調用 getSharedPreference()
之后,馬山又調用 getXxx()
方法,就有可能出現 ANR 的情況。
因為加載文件數據到內存中是在一個子線程中去執行的,因此在為了保證數據的同步性,在調用 getXxx()
等方法時,會先調用 awaitLoadedLocked()
判斷數據是否已經加載到內存中,如果 mLoaded = false 就表示沒有加載完成,這時是出于一個 wait 狀態,這時如果本地的 mFile
文件比較大的話,那么如果在主線程調用 getXxx(),那么就有可能出現 ANR 現象,因為它會等待直到數據加載完成,這時 mLoaded = true,才會去釋放鎖。
public int getInt(String key, int defValue) {
synchronized (this) {
awaitLoadedLocked();
Integer v = (Integer)mMap.get(key);
return v != null ? v : defValue;
}
}
5.3、 示例3
如果通過 apply
來保存數據,但是馬上就調用 getXxx() 方法的話,這時保存數據和獲取數據是異步
的,因此 getXxx() 得到的數據可能為空或者是舊的數據。
5.4、 示例4
在4.4節中,調用writeToFile(mcr)將 mMap 數據寫入到文件中,這個操作是全量數據mMap寫入到文件中,而不是增量寫入,因此不管是調用 commit
還是 apply
時,最好全部通過 Editor
進行修改數據之后,再進行寫入操作,而不是一次修改就一次寫入。
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
六、 sp 中幾種文件創建模式
- MODE_PRIVATE
默認的模式,當前創建的文件只能被當前 Application 使用。
- MODE_WORLD_READABLE
讀模式,允許其他應用程序讀取該文件,在 Android N 之后會有一個 SecurityException
異常。@Deprecated
- MODE_WORLD_WRITEABLE
寫模式,允許其他應用程序寫入該文件,在 Android N 之后會有一個 SecurityException
異常。@Deprecated
- MODE_MULTI_PROCESS
多進程模式,這種模式是不安全的,官方不建議使用,可以使用 ContentProvider 來代替。當設置MODE_MULTI_PROCESS模式, 則每次getSharedPreferences過程, 會檢查SP文件上次修改時間和文件大小, 一旦所有修改則會重新從磁盤加載文件。@Deprecated
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
//1. 檢查 mode ,不符合會拋出異常。
checkMode(mode);
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
//2,多進程模式,重新從本地加載數據
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
// If somebody else (some other process) changed the prefs
// file behind our back, we reload it. This has been the
// historical (if undocumented) behavior.
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
private void checkMode(int mode) {
if (getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.N) {
if ((mode & MODE_WORLD_READABLE) != 0) {
throw new SecurityException("MODE_WORLD_READABLE no longer supported");
}
if ((mode & MODE_WORLD_WRITEABLE) != 0) {
throw new SecurityException("MODE_WORLD_WRITEABLE no longer supported");
}
}
}
- 1、 checkMode 檢查傳入的 mode 是否合適。
從以下
checkMode
方法源碼可以知道, Google 在 Android N 之后,就不再支持MODE_WORLD_READABLE
和MODE_WORLD_WRITEABLE
模式了。這樣對用戶的隱私權限進一步的收緊,如果有這方面的需求還是建議使用FileProvider
內容提供者來實現。
- 2、 如果是
MODE_MULTI_PROCESS
模式,那么建議不要在保存 SharedPreference 的實例,每次調用時都應該調用 getSharedPreference() 來確保數據是從本地加載到的。
記錄于 2019年1月31日