SharedPreferences Optimization

The problems of SharedPreferences

The design of SharedPreferencesopen in new window has always been criticized, actually, the SharedPreferencesopen in new window was designed by Google's engineers was not intented to be used as it is now. However, it has been used widely in an unexpected way, and caused plenty of UI janks and ANRs.

How does SharedPreferencesopen in new window cause UI janks and ANRs, this has to start from the SharedPreferences.Editor.apply()open in new window, as shown in the following diagram:

From the diagram above, we can see the QueuedWork.waitToFinish()open in new window is called in the Activity.onPause(), and the waitToFinish() is responsible for executing the Runnable that was previously added to the QueuedWork by Editor.apply() in the main thread synchronously, as shown in the following code:

final class SharedPreferencesImpl implements SharedPreferences {

    public final class EditorImpl implements Editor {

       @Override
        public void apply() {
            final long startTime = System.currentTimeMillis();

            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                @Override
                public void run() {
                    try {
                        /*👇👇👇👇 Where the ANR occurred 👇👇👇👇*/
                        mcr.writtenToDiskLatch.await();
                    } catch (InterruptedException ignored) {
                    }

                    if (DEBUG && mcr.wasWritten) {
                        Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                                + " applied after " + (System.currentTimeMillis() - startTime)
                                + " ms");
                    }
                }
            };

            /*👇👇👇👇 Enqueue an asynchronous runnable 👇👇👇👇*/
            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);
        }

    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
    /**
     * Trigger queued work to be processed immediately. The queued work is processed on a separate
     * thread asynchronous. While doing that run and process all finishers on this thread. The
     * finishers can be implemented in a way to check weather the queued work is finished.
     *
     * 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() {
        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;
                }

                /*👇👇👇👇👇 Executed synchronously in main thread 👇👇👇👇*/
                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: ");
                }
            }
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

Invoke commit() asynchronouly

The optimization for SharedPreferencesopen in new window introduced in booster v0.1.5open in new window is replacing the Editor.apply()open in new window with Editor.commit()open in new window and executing it in a worker thread, as shown in the following code:

public class ShadowEditor {

    public static void apply(final SharedPreferences.Editor editor) {
        if (Looper.myLooper() == Looper.getMainLooper()) {
            AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() {
                @Override
                public void run() {
                    editor.commit();
                }
            });
        } else {
            editor.commit();
        }
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

The advantage of this solution is easy to implement with small changes, the disadvantage is obvious, in some cases, there may be a bug, for example, a getXxx() call followed the commit(), as showned in the following code:

SharedPreferences sp = activity.getSharedPreferences(Context.MODE_PRIVATE);
sp.edit().putString("key", "value").commit();
String value = sp.getString("key");
1
2
3

WARNING

Although the example above is not a best practice, executing commit() asynchronously probably cause a data inconsistency problem.

Custom SharedPreferences

To solve the problems of SharedPreferences completely, the BoosterSharedPreferencesopen in new window has been introduced in Booster v0.27.0open in new window, all invocations of Context.getSharedPreferences(String, int) will be replaced with ShadowSharedPreferences.getSharedPreferences(Context, String, int) by SharedPreferencesTransformeropen in new window, as shown in the following code:

public class ShadowSharedPreferences {

    public static SharedPreferences getSharedPreferences(
        final Context context,
        final String name,
        final int mode
    ) {
        if (TextUtils.isEmpty(name)) {
            name = "null";
        }
        return BoosterSharedPreferences.getSharedPreferences(name);
    }

    public static SharedPreferences getPreferences(
        final Activity activity,
        final int mode
    ) {
        return getSharedPreferences(
            activity.getApplicationContext(),
            activity.getLocalClassName(),
            mode
        );
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

Then, it's able to avoid executing QueuedWork synchronously in lifecycle callbacks within main thread by using custom SharedPreferences implementation.

MMKVopen in new window based SharedPreferences

We have considered this solution, but finally gave up due to the following reasons:

  1. MMKV doesn't implement the OnSharedPreferenceChangeListeneropen in new window

    public class MMKV implements SharedPreferences, SharedPreferences.Editor {
    
        @Override
        public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
            throw new java.lang.UnsupportedOperationException("Not implement in MMKV");
        }
    
        @Override
        public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
            throw new java.lang.UnsupportedOperationException("Not implement in MMKV");
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
  2. MMKV doesn't verify the data type, so, the following code is considered legal:

    mmkv.edit().putInt("a", 1).apply();
    boolean result = mmkv.getBoolean("a", false);
    // result is true
    
    1
    2
    3
  3. Inconsistent behavior problem, for example, the result of MMKV is different from the result of Android SDK:

    editor.put("a", "abc").clear().apply();
    
    1

So, considering the problems above, Booster hasn't provided an implementation based on MMKV, if you are interested in this solution, and the problems above are acceptable, you can have your own implementation, more details, please refer to booster-transform-shared-preferencesopen in new window and booster-android-instrument-shared-preferencesopen in new window.