SharedPreferencesって変更差分はメモリ上で保持していて、最初の読み出しはどうしようもないだろうけど、そのあとはapplyとか使っていれば安全だと思っていた時期が俺にもありました。しかしDataStoreの説明で
SharedPreferences には、UI スレッドで呼び出しても安全に見える同期 API がありますが、これは実際にはディスク I/O オペレーションを行います。さらに、apply() は fsync() で UI スレッドをブロックします。fsync() 呼び出しの保留は、サービスの開始と停止、およびアプリケーションの任意の場所でアクティビティが開始または停止するたびにトリガーされます。UI スレッドは、apply() によってスケジュールされた保留中の fsync() 呼び出しによってブロックされます。多くの場合、ANR の発生元になります。
な、なんだってー
マジかよ!?apply
使ってれば大丈夫なんじゃ?ってなったので、実際どういうことなのかを調べてみることにしました。
SharedPreferencesの実装
SharedPreferencesそのものはinterfaceで中身がありません。
ContextImplにSharePreferencesのインスタンスを作っている処理があります。
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// At least one application in the world actually passes in a null
// name. This happened to work because when we generated the file name
// we would stringify it to "null.xml". Nice.
if (mPackageInfo.getApplicationInfo().targetSdkVersion <
Build.VERSION_CODES.KITKAT) {
if (name == null) {
name = "null";
}
}
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
if (file == null) {
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
checkMode(mode);
if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {
if (isCredentialProtectedStorage()
&& !getSystemService(UserManager.class)
.isUserUnlockingOrUnlocked(UserHandle.myUserId())) {
throw new IllegalStateException("SharedPreferences in credential encrypted "
+ "storage are not available until after user is unlocked");
}
}
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) {
// 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;
}
@GuardedBy("ContextImpl.class")
private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
if (sSharedPrefsCache == null) {
sSharedPrefsCache = new ArrayMap<>();
}
final String packageName = getPackageName();
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
sSharedPrefsCache.put(packageName, packagePrefs);
}
return packagePrefs;
}
SharedPreferencesImplというまんまの名前のクラスが実装のようです。また、ファイル名をKeyとしてキャッシュされており、同じファイル名のSharedPreferencesは同じインスタンスが使われるようになっていることも分かりますね。
SharedPreferencesImplの実装は以下になります。これを読み込んでいきましょう。
初回読み出し
SharedPreferencesは永続化のためにXMLファイルにデータを書き出しているため、当然最初に読み出しが必要です。まずは読み出し周りがどうなっているのかを調べて見ましょう。
コンストラクタ周りの実装は以下のようになっていて、コンストラクタの時点でスレッドが起動し、そのスレッドでファイルの読み出しが始まります。このスレッドからXMLファイルの読み出しとパース、結果をMap<String, Object>
に格納しています。
@UnsupportedAppUsage
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
startLoadFromDisk();
}
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
getSharedPreferencesをUIスレッドでコールしており、その時点でまだSharedPreferencesのインスタンスを確保できていなかったとしても、読み出し処理は独立したスレッドで行われるので、この時点ではまだUIスレッドをブロックすることはありません。
続いて、データの読み出しを見てみましょう。同期が必要なのでmLockのsynchronizedで囲まれています。
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
データの読み出し自体はMap<String, Object>
から読み出して、キャストしているので、オンメモリで行われ、ブロックする要素はないですね。awaitLoadedLocked
を調べてみましょう。
@GuardedBy("mLock")
private void awaitLoadedLocked() {
if (!mLoaded) {
// Raise an explicit StrictMode onReadFromDisk for this
// thread, since the real read will be in a different
// thread and otherwise ignored by StrictMode.
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}
はい、コンストラクタで呼び出した読み出しスレッドの完了を待っています。このスレッドではIO処理を行っているわけではありませんが、IO処理を行っているスレッドの完了を待っているので、ここでUIスレッドがブロックされます。このタイミングでThreadPolicyのReadFromDiskを通知していますね。
読み出し処理によるUIスレッドのブロック
以上の調査から、SharedPreferencesの保存したファイルの読み出し、パース処理は独立したスレッドで実行される。ただし、データの読み出し処理は読み出しスレッドの完了を待つ必要があり、読み出し処理の完了を知る手段もないため、読み出し処理が完了するまでの間にデータを読み出そうとするとIO処理の完了までUIスレッドがブロックされることが分かりました。
ただし、一回読み出しが完了してしまえばブロックされる要素がなくなるためUIスレッドをブロックする可能性があるのは初回の読み出しのみということになります。
書き出し処理
続いて、書き出し処理を調べて見ましょう。SharedPreferencesの変更のためにはSharedPreferences.edit()
をコールして、Editorを取得し、Editorに対して変更を行います。
@Override
public Editor edit() {
// TODO: remove the need to call awaitLoadedLocked() when
// requesting an editor. will require some work on the
// Editor, but then we should be able to do:
//
// context.getSharedPreferences(..).edit().putString(..).apply()
//
// ... all without blocking.
synchronized (mLock) {
awaitLoadedLocked();
}
return new EditorImpl();
}
実体はEditorImplですね。
public final class EditorImpl implements Editor {
private final Object mEditorLock = new Object();
@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();
@GuardedBy("mEditorLock")
private boolean mClear = false;
@Override
public Editor putString(String key, @Nullable String value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}
EditorImplの中では、SharedPreferencesで保持しているMapを変更するのではなく、変更要素だけを一旦保存していく仕組みですね。
実際の反映はapply()
をコールした場合ですね。(commit()
については、apply()
の完了待ち合わせが行われるだけなので割愛)
ここが本題なので丁寧に追っていきましょう。
@Override
public void apply() {
final long startTime = System.currentTimeMillis();
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(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);
}
apply メモリー上への変更の反映
まずは、commitToMemory()
メモリー上の変化の反映をしているみたいですね。排他のための処理も含まれるためややこしいです。
OnSharedPreferenceChangeListener
をコールする処理があったりしますが、これに関しては、へー、このタイミングでコールされるんだー程度で流します。
// Returns true if any changes were made
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
boolean keysCleared = false;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
// 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);
}
mapToWriteToDisk = mMap;
mDiskWritesInFlight++;
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
keysModified = new ArrayList<String>();
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (mEditorLock) {
boolean changesMade = false;
if (mClear) {
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
mapToWriteToDisk.clear();
}
keysCleared = true;
mClear = false;
}
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 (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else {
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mapToWriteToDisk.put(k, v);
}
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
mModified.clear();
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
listeners, mapToWriteToDisk);
}
まず最初にやっているのが、先行の書き込み処理とのコンフリクト対策ですね。
// 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);
}
mapToWriteToDisk = mMap;
mDiskWritesInFlight++;
書き込み処理が別に走っている場合、そちらが参照しているデータを変更してしまうと不整合が発生してしまいますので、mMapのコピーを作成し、参照の差し替えを行っています。先行の書き込み処理はmapToWriteToDiskに保持しているコピー元のmMapを参照しているため、以降の処理が先行の処理に影響を与えないようにしているわけですね。ただ、コピーはコストが高いのであくまでコンフリクトの可能性がある場合だけってことですね。
その次はListenerの処理なので飛ばして、mEditorLockの中ですね。
if (mClear) {
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
mapToWriteToDisk.clear();
}
keysCleared = true;
mClear = false;
}
clearがコールされていた場合ですね。空にしてしまいます。
続いて、Editorに対して行われた変更のうち、反映すべきものを調べています。
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 (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else {
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mapToWriteToDisk.put(k, v);
}
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
v == this
はremove()
をコールした場合にvalueにthisを格納して、削除フラグとして利用しているようですね。nullをputされた場合も削除として扱うようです。
削除の場合ですでに値がない場合、書き込みの場合で格納されている値と同じ場合、何も行いません。余計な書き込みをしないようにSharedPreferencesにすでにある値と同じかどうかをアプリでチェックする必要はなさそうです。
また、clearの処理が先頭で行われているので、editのあとputしてからclearした場合、先のputの結果はclearによって消されることはなく書き込まれるようですね。普通そういうことはしないと思いますが。
mModified.clear();
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
listeners, mapToWriteToDisk);
最後に、このEditorによって変更が行われる場合、世代をインクリメントして最後に結果をMemoryCommitResult
という形で返しています。
Mapのコピーなどやや重い処理をsynchronizedで囲っているとはいえ、全部オンメモリでの操作なのでここまではANRが発生するほどの要素はなさそうですね。
apply 書き込み処理のリクエスト
メモリー上への反映の後の処理を見ていきましょう、またapply()
に戻ります。
@Override
public void apply() {
final long startTime = System.currentTimeMillis();
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(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);
}
awaitCommit
とpostWriteRunnable
という2つのRunnableを作っています。
これらは、MemoryCommitResult.setDiskWriteResult()
がコールされたことを確認するためのCoundDownLatchを待つための処理です。
awaitCommit
はQueuedWork
にFinisher
として登録されます。postWriteRunnable
はawaitCommit
を実行後、QueuedWork
から削除する処理が追加されています。
通常であれば書き込みからsetDiskWriteResult
のコール、ラッチの待ち合わせとシーケンシャルに行われるので無駄な待ち合わせに見えますね(伏線)。
この部分は置いておいて先を読んでいきましょう。
最終的にenqueueDiskWrite
をコールしています。いよいよ書き込み処理に近づいてきましたね。postWriteRunnable
は名前の通り、書き込み処理が終わったらコールされる処理のようですね。
enqueueDiskWrite
enqueueDiskWriteの中を読んでいきましょう。
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final boolean isFromSyncCommit = (postWriteRunnable == null);
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
最初にpostWriteRunnable
のnull判定をしています。commit
の場合、これがnullになるのですね。
次にwriteToDiskRunnable
というRunnableを作っています。writeToFile
という書き込み処理を行い、終わったら、commitToMemoryでインクリメントしたmDiskWritesInFlightをデクリメントして、書き込み処理中を解除、postWriteRunnableを実行するといった流れです。
このRunnableはapply
の場合、queueに積まれますが、commit
の場合で、他に書き込み処理が実行されていない場合、このスレッド上でrun()をコールしてしまいます。applyの書き込み処理が走っている場合は、そちらの書き込みより先に実行してしまうと問題が起こるのでQueueに積まれます。
先にwriteToFile
の中を見てみましょう。
書き込む必要が無い場合はスキップするとか、書き込み中の処理中断時用のバックアップファイルをつくっているとか、ありますが、やっていることは、データをXMLフォーマットで書き出しているだけです。そして一部抜粋。
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
FileUtils.sync(str);
str.close();
FileUtils.sync()
単にファイルに書き出すだけでなくfsync
も実行して書き込みを確定していますね。これをUIスレッドで実行するのであれば、確かに最初の説明通りです。
では、QueuedWork.queueの中を見てみましょう。
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();
synchronized (sLock) {
sWork.add(work);
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
shouldDelay
はapply
のときtrue、commit
のときfalseで、apply
の場合に100msのディレイがあるという違いはありますが、やっていることは同じです。sWork
というqueue(実体はLinkedList)にRunnableを追加して、Handlerにメッセージを投げています。
Handlerを作っている箇所が以下
@UnsupportedAppUsage
private static Handler getHandler() {
synchronized (sLock) {
if (sHandler == null) {
HandlerThread handlerThread = new HandlerThread("queued-work-looper",
Process.THREAD_PRIORITY_FOREGROUND);
handlerThread.start();
sHandler = new QueuedWorkHandler(handlerThread.getLooper());
}
return sHandler;
}
}
新規にHandlerThread
をつくって、そのLooper
を使うQueuedWorkHandler
のインスタンスをHandlerとしている。
QueuedWorkHandler
とは以下で、先ほどのメッセージを受けてprocessPendingWork
を実行するだけのHandlerですね。
private static class QueuedWorkHandler extends Handler {
static final int MSG_RUN = 1;
QueuedWorkHandler(Looper looper) {
super(looper);
}
public void handleMessage(Message msg) {
if (msg.what == MSG_RUN) {
processPendingWork();
}
}
}
processPendingWork
は以下のようにsWork
の中身を全部実行するだけ。
private static void processPendingWork() {
long startTime = 0;
if (DEBUG) {
startTime = System.currentTimeMillis();
}
synchronized (sProcessingWork) {
LinkedList<Runnable> work;
synchronized (sLock) {
work = sWork;
sWork = new LinkedList<>();
// Remove all msg-s as all work will be processed now
getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
}
if (work.size() > 0) {
for (Runnable w : work) {
w.run();
}
if (DEBUG) {
Log.d(LOG_TAG, "processing " + work.size() + " items took " +
+(System.currentTimeMillis() - startTime) + " ms");
}
}
}
}
以上!
UIスレッドをブロックしている犯人
あれ?書き込み処理はUIスレッドで実行してないし、UIスレッド待たせてないじゃん? って思いましたが違います。
waitToFinishというメソッドがあって、processPendingWork
をこのスレッド内で実行しています。
しかも、StrictModeを回避しているという。
Finisher
というのはここで出てきます。HandlerThreadの方で実行が進んでいる場合に、その完了を待つために使われているわけですね。当然、そちらで実行されていても、このメソッドのコールスレッドがブロックされることになります。
public static void waitToFinish() {
long startTime = System.currentTimeMillis();
boolean hadMessages = false;
Handler handler = getHandler();
synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);
if (DEBUG) {
hadMessages = true;
Log.d(LOG_TAG, "waiting");
}
}
// We should not delay any work as this might delay the finishers
sCanDelay = false;
}
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
processPendingWork();
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
try {
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
finisher.run();
}
} finally {
sCanDelay = true;
}
synchronized (sLock) {
long waitTime = System.currentTimeMillis() - startTime;
if (waitTime > 0 || hadMessages) {
mWaitTimes.add(Long.valueOf(waitTime).intValue());
mNumWaits++;
if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
mWaitTimes.log(LOG_TAG, "waited: ");
}
}
}
}
このメソッドはActivityThread
からコールされています。
@Override
public void handleStopActivity(ActivityClientRecord r, int configChanges,
PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) {
r.activity.mConfigChangeFlags |= configChanges;
final StopInfo stopInfo = new StopInfo();
performStopActivityInner(r, stopInfo, true /* saveState */, finalStateRequest,
reason);
if (localLOGV) Slog.v(
TAG, "Finishing stop of " + r + ": win=" + r.window);
updateVisibility(r, false);
// Make sure any pending writes are now committed.
if (!r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
stopInfo.setActivity(r);
stopInfo.setState(r.state);
stopInfo.setPersistentState(r.persistentState);
pendingActions.setStopInfo(stopInfo);
mSomeActivitiesChanged = true;
}
一例はhandleStopActivity
です。performStopActivityInner
をコールしているように、ActivityのonStop
を実行しているメソッドですね。他にもhandleServiceArgs
(ServiceのonStartCommandを実行)、handleStopService
(ServiceのonDestroyを実行)などでコールされていますね。
fsync() 呼び出しの保留は、サービスの開始と停止、およびアプリケーションの任意の場所でアクティビティが開始または停止するたびにトリガーされます。
ギャー、そういうことだったのかー
まとめ
まとめると
-
SharedPreferencesのインスタンスが作られると、独立したスレッドでXMLの読み出しが行われる。
- 読み出しが完了する前にSharedPreferencesアクセスすると呼び出しスレッドがブロックされる
-
SharedPreferencesを変更すると、ワーカースレッドでXMLの書き込みが遅延実行される
- apply/commitを複数回コールすると、その回数分XMLの全書き込みが行われる
- ActivityのonStop・ServiceのonStartCommand/onDestroyなどのタイミングで
- 書き込み処理が実行中だった場合、完了までUIスレッドをブロックする
- キューに書き込みタスクが残っている場合、UIスレッドで残タスクを実行する
といったところでしょうか。
SharedPreferencesがどういう動きをしているのかよく分かりましたね。
SharedPreferencesは、ANRの危険はありつつも、今までずっと使われてきた仕組みなので、使い続けることが悪いというわけでもなく、絶対にDataStoreに移行しなければならないというものでもないと思います。
使い続けるならこの辺の特性を理解した上で、下手な使い方はしないように注意していきましょう。
以上です。