本文目錄:
- 寫在前面
- 獲取 SharedPreferences 實例
- 加載 xml 數據文件
- 初次讀取數據的耗時分析
- commit 和 apply 的對比
寫在前面
SharedPreferences 平時用來持久化一些基本數據類型或者一些可序列化的對象。
根據我們日常的經常,持久化操作是耗時的,涉及到文件的 IO 操作,但是實際使用 SharedPreferences 時,發現只有第一次讀取數據是有概率卡主線程幾十到幾百毫秒,而之后的讀取時間幾乎可以忽略不計。
我們有了這樣的疑問:
- 為什么初次讀取數據會有概率的阻塞?
- 為什么除了初次讀取數據可能阻塞,而可以在后面的讀取很快?
- 為什么都推薦使用 apply 而不是 commit 提交數據?
帶著問題去理解它的實現。
獲取 SharedPreferences 實例
SharedPreferences 是由 Context 返回的,比如我們的 Application,Activity。所以具體的實現每個應用的上下文環境有關,每個應用有自己的單獨的文件夾存放這些數據,對其他應用不可見。
獲取 SharedPreferences 的方法定義在抽象類 Context 中:
public abstract SharedPreferences getSharedPreferences(String name, int mode);
public abstract SharedPreferences getSharedPreferences(File file, int mode);
如果查看 Application 或者 Activity 的源碼,會找不到具體的實現。這是因為它們繼承了 ContextWrapper,代理模式,代理 ContextImpl 的實例 mBase 中。ContextImpl 是具體的實現。
兩種獲取 SharedPreferences 的方法中,我們基本上用的是 getSharedPreferences(String name, int mode);
,參數只傳了文件的名字。
查看內部的代碼可以看到,雖然只有一個名字,ContextImpl 會構建出文件的具體路徑。再接著調用 getSharedPreferences(File file, int mode);
方法返回 SharedPreferencesImpl 實例。
所以 SharedPreferences 的操作,本質上就是對文件的操作。最后會落實到一個 xml 文件上:
@Override
public File getSharedPreferencesPath(String name) {
return makeFilename(getPreferencesDir(), name + ".xml");
}
標準路徑在 /data/data/應用包名/shared_prefs
文件夾中,且都是 xml 文件。
創建好 File 對象后,會在 getSharedPreferences(File file, int mode)
中打開文件并執行初始操作,把 SharedPreferencesImpl 實例返回:
@Override
public SharedPreferences getSharedPreferences(File file, int 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;
}
}
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
這里我們看到了另一個重要的類 SharedPreferencesImpl,它和 ContextImpl 一樣,是接口的具體實現類。
每一個 File 文件對應一個 SharedPreferencesImpl 實例。為了提高效率,ContextImpl 有做緩存 cache,這里的緩存是強引用,在整個進程的生命周期中都存在,意味著每個文件的 SharedPreferencesImpl 實例在整個進程中只會被創建一次。
這個方法的末尾有一個特殊的處理需要注意一下,是關于模式 Context.MODE_MULTI_PROCESS
,可以看到在這個模式下,會調用:
sp.startReloadIfChangedUnexpectedly();
這個方法執行下去,會檢查文件是否被修改了,如果文件被修改了,會調用 startLoadFromDisk
來更新文件。因為多進程環境下,這里的文件有可能被其他進程修改。
加載 xml 數據文件
為什么除了初次讀取數據可能卡頓,而可以在后面的讀取很快?
我們進入 SharedPreferences 的加載流程,就是把文件的內容載入內存的過程。
載入文件的方法在 startLoadFromDisk
中,顧名思義,就是開始從磁盤加載數據。
調用該方法有兩個地方:
- 構造函數里會被調用。所以第一次創建 SharedPreferencesImpl 會馬上把文件內容載入內存。
- 在
Context.MODE_MULTI_PROCESS
下,文件發生修改時被調用。目的就是多進程下更新數據。
startLoadFromDisk
方法如下:
private void startLoadFromDisk() {
synchronized (this) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
可以看到直接開啟一個新線程,調用 loadFromDisk 加載文件:
private void loadFromDisk() {
...
str = new BufferedInputStream(
new FileInputStream(mFile), 16*1024);
map = XmlUtils.readMapXml(str);
...
}
本質上,就是讀取一個 xml 文件,被內容解析為 Map 對象。這個 map 包含了我們之前保存的所有鍵值對的數據。并且把 map對象保存為 mMap 成員變量,直接在內存中常駐:
synchronized (SharedPreferencesImpl.this) {
mLoaded = true;
if (map != null) {
mMap = map
...
} else {
mMap = new HashMap<>();
}
notifyAll();
}
這里可以解釋我們的疑問,為什么 SharedPreferences 的讀取非常快,載入完成后,后面的讀操作都是針對 mMap 的,響應速度是內存級別的非常快。比如 getString:
public String getString(String key, @Nullable String defValue) {
synchronized (this) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
我們也就可以理解為什么 SharedPreferences 不希望存大量數據了,一個很重要的原因也是內存緩存,如果數據量很大的話,這里會占據很大一塊內存。
初次讀取數據的耗時分析
為什么初次讀取數據會有概率的阻塞?
對應用性能監控中發現,SharedPreferences 初次讀取數據的會發現概率發生阻塞,一般會被卡 20~40 ms。如果系統 IO 原本就繁忙的話,甚至可能會卡好幾秒。
所以在應用啟動中,我們去獲取一些配置,不得不在主線程對 SharedPreferences 進行初次操作。如果在短時間內讀取多個 不同的 SharedPreferences,應用的啟動會耗費很長的時間。
這和一個鎖有關,就是 SharePreferencesImpl.this。在初始化加載文件的時候,和讀取數據的時候都會用到這個鎖。
在 SharePreferencesImpl 構造中,調用 loadFromDisk ,加鎖保護了對 mLoad 和 mMap 的讀寫:
private void loadFromDisk() {
synchronized (SharedPreferencesImpl.this) {
...
}
}
而每次讀取數據的時候,也加了這個鎖去保護這些成員,比如 getString:
public String getString(String key, @Nullable String defValue) {
synchronized (this) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
所以這里形成了一個競爭關系,如果在本地 xml 文件的加載過程中,先執行了 loadFromDisk,那么 getString 就會阻塞等待。
loadFromDisk 是 IO 耗時操作,雖然 loadFromDisk 操作被分配到另一個線程執行,但因為讀取數據的時候,爭用了這個鎖,會發生概率卡頓。
commit 和 apply 的對比
為什么都推薦使用 apply 而不是 commit 提交數據?
先看我們平時修改 SharedPreferences 的姿勢:
SharedPreferences sp = context.getSharedPreferences("test", Mode.PRIVATE);
Editor editor = sp.edit();
editor.putString("key", "Hello World!");
editor.commit(); 或者 editor.apply();
可以看到具體修改被它的內部類 EditorImpl 接管,最后才調用 commit 或者 apply,而這兩者的區別就是我們要討論的。
EditorImpl 內部有一個內存緩存,用來保存用戶修改后的操作:
private final Map<String, Object> mModified = Maps.newHashMap();
在執行 commit 或者 apply 前,比如上面的 editor.putString("key","Hello World!")
會把修改存儲在 mModified 中:
public Editor putString(String key, @Nullable String value) {
synchronized (this) {
mModified.put(key, value);
return this;
}
}
}
到這里,只是把修改緩存在了內存中。然后調用 commit 和 apply 把修改持久化。
這兩個方法都會調用一個 commitToMemory 方法,做兩件事情:
- 一個是把修改提交到內存
- 創建 MemoryCommitResult 用來做后面的本地 IO。
修改很簡單,就是遍歷 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);
}
}
而 MemoryCommitResult 是一個數據容器,記錄著一些后面進行磁盤寫入操作需要使用到的數據,比如有:
-
boolean changesMade
,標記變量,用來標記數據是否發生改變。 -
Map<?, ?> mapToWriteToDisk
, 最終要寫入到本地的數據,會指向 SharedPreferencesImpl 的內存緩存 mMap
同步修改到 mMap
經過這個階段,內存的數據就被更新了。并創建好 MemoryCommitResult 對象后,接下來就是不一樣的操作。
先看 commit 方法:
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;
}
在調用 enqueueDiskWrite 的時候,因為沒有構建 postWriteRunnable,最終會在當前線程直接執行寫入操作:
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
...
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (SharedPreferencesImpl.this) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
...
}
直接調用 writeToDiskRunnable.run()
沒有再開線程,直接阻塞寫入。
apply 方法:
public void apply() {
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
QueuedWork.add(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
public void run() {
awaitCommit.run();
QueuedWork.remove(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}
可以看到先構造了一個 postWriteRunnable 傳入 enqueueDiskWrite。
在方法的執行中,可以看到最后會在一個單線程線程池 QueuedWork.singleThreadExecutor()
中執行寫入操作:
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
...
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
所以,commit 是阻塞的,apply 是非阻塞的。
平時使用的時候,盡量使用 apply 避免卡主主線程。因為寫入前都已經更新修改到緩存了,不用擔心讀到臟數據。