Android source code analysis: Analysis of SharedPreferences

Android source code analysis: Analysis of SharedPreferences

Introduction

SharedPreferences is a lightweight data persistence method in Android. It may also be the first special local data persistence method we come into contact with when learning Android. This article will analyze the principle of SharedPreferences from the source code perspective.

Source code analysis

Generally we use SharedPreferences like this:

//Use of sp--write data
val sp = getPreferences(Context.MODE_PRIVATE)
val editor = sp.edit()
editor.putString("cc","123")
editor.apply()
//Read data
val ans = sp.getString("cc","null")
Toast.makeText(this, ans, Toast.LENGTH_SHORT).show()

Next, we will use this program as an example to analyze the principle of SharedPreferences.

Get the Preferences object

There are many ways we can obtain the Preferences object:

  • getPreferences(int mode)
  • getDefaultSharedPreferences(context context)
  • getSharedPreferences(String key,int mode)

In this example, we take the getPreferences method as an example. In fact, the complete display of this method should be getActivity().getPreferences(), which means it must be called on Activity This method, let’s look at this method:

public SharedPreferences getPreferences(@Context.PreferencesMode int mode) {<!-- -->
    return getSharedPreferences(getLocalClassName(), mode);
}

You can see that this method will eventually call the getSharedPreferences(String key, int mode) method, but here the keyword is passed to the first parameter with the class name of this Activity. Next we Continue to jump to the second method:

public SharedPreferences getSharedPreferences(File file, int mode) {<!-- -->
    return mBase.getSharedPreferences(file, mode);
}

This eventually calls the Context method associated with the Activity. As expected, this mBase should be ContextImpl. Let’s take a look at this method:

 public SharedPreferences getSharedPreferences(String name, int mode) {<!-- -->
...
        File file;
        //Synchronize code block, lock with ContextImpl class as lock
        synchronized (ContextImpl.class) {<!-- -->
            //When the file path has not been loaded yet
            if (mSharedPrefsPaths == null) {<!-- -->
                //Create a Map to store file paths
                mSharedPrefsPaths = new ArrayMap<>();
            }
            //Try to find path from file path storage
            file = mSharedPrefsPaths.get(name);
            //If the specific file cannot be found, it means that the file has not been created yet.
            if (file == null) {<!-- -->
            //Call the getSharedPreferencesPath method
                file = getSharedPreferencesPath(name);
                //Put the newly created file path into the Map
                mSharedPrefsPaths.put(name, file);
            }
        }
        //Jump to another overloaded method
        return getSharedPreferences(file, mode);
    }

I have added comments to the important code parts. The method here is to create a Map to store the Sp object (path) under the same Context. If the target Sp object does not exist, create an Sp object and then store it. In Map, let’s first look at the getSharedPreferencesPath method:

public File getSharedPreferencesPath(String name) {<!-- -->
    return makeFilename(getPreferencesDir(), name + ".xml");
}

You can see that this method actually creates a new file. The parent path is the value of getPreferencesDir(), and the subpath is name + xml, which means it is created. is an xml file, which means that Sp actually stores specific data through xml files.

Then let’s look at the other method we jumped to at the end:

public SharedPreferences getSharedPreferences(File file, int mode) {<!-- -->
//Actual Sp implementation class
    SharedPreferencesImpl sp;
    //Still using ContextImpl as the lock for synchronization
    synchronized (ContextImpl.class) {<!-- -->
    //Get Sp cache
        final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
        //Get the specific instance of Sp
        sp = cache.get(file);
        //When Sp cannot be successfully obtained from the cache
        if (sp == null) {<!-- -->
            checkMode(mode);
            if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {<!-- -->
                if (isCredentialProtectedStorage()
                         & amp; & amp; !getSystemService(UserManager.class)
                                .isUserUnlockingOrUnlocked(UserHandle.myUserId())) {<!-- -->
                    throw new IllegalStateException("SharedPreferences in credential encrypted "
                             + "storage are not available until after user is unlocked");
                }
            }
            //Create a new Sp instance
            sp = new SharedPreferencesImpl(file, mode);
            //Add to cache
            cache.put(file, sp);
            //Return Sp instance
            return sp;
        }
    }
    if ((mode & amp; 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;
}

I have also marked the important logic of this code. What we need to look at may be the process of obtaining the cache by getSharedPreferencesCacheLocked():

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;
 }

You can see that it obtains the current Sp cache through another cache, that is to say, it accesses data through two levels of cache. The upper-level cache is used to cache caches under the same package name, and the lower-level cache is used to obtain specific Sp instances.

So to sum up, when there is no corresponding Sp instance in the cache, create an Sp instance and stuff it into the cache. If there is one in the cache, the corresponding Sp instance is returned directly.

Commit to submit changes

First of all, we need to find this method by going to the internal class EditorImpl in the specific implementation class SharedPreferencesImpl of Sp. However, before analyzing this method, we need to look at another method first. commitToMemory, which is also a method in EditorImpl:

private MemoryCommitResult commitToMemory() {<!-- -->
    long memoryStateGeneration;
    boolean keysCleared = false;
    List<String> keysModified = null;
    Set<OnSharedPreferenceChangeListener> listeners = null;
    Map<String, Object> mapToWriteToDisk;
//Synchronize using the current Sp instance (the external class instance held) as the lock
    synchronized (SharedPreferencesImpl.this.mLock) {<!-- -->
    //When there are still unfinished disk writes
        if (mDiskWritesInFlight > 0) {<!-- -->
        //Update the Map and write the previous Map content into the current Map
            mMap = new HashMap<String, Object>(mMap);
        }
        //Update the Map to be written to disk
        mapToWriteToDisk = mMap;
        //The tag value being written +
        mDiskWritesInFlight + + ;
//Determine whether there is a listener
        boolean hasListeners = mListeners.size() > 0;
        //If there is a listener
        if (hasListeners) {<!-- -->
            keysModified = new ArrayList<String>();
            listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
        }
//wait for edit lock
        synchronized (mEditorLock) {<!-- -->
            boolean changesMade = false;
//If the Clear bit is true
            if (mClear) {<!-- -->
            //The map written to disk is not empty
                if (!mapToWriteToDisk.isEmpty()) {<!-- -->
                //Modify position to true
                    changesMade = true;
                    mapToWriteToDisk.clear();
                }
                keysCleared = true;
                mClear = false;
            }
\t\t\t
//Traverse the data in the Map that needs to be modified
            for (Map.Entry<String, Object> e : mModified.entrySet()) {<!-- -->
                String k = e.getKey();
                Object v = e.getValue();
                // When v == this or when v is empty
                // v == this corresponds to the remove method
                if (v == this || v == null) {<!-- -->
                // When the map that needs to be written to disk does not contain the current key, skip this loop directly.
                    if (!mapToWriteToDisk.containsKey(k)) {<!-- -->
                        continue;
                    }
                    //Remove it from the map that needs to be written to disk
                    mapToWriteToDisk.remove(k);
                } else {<!-- -->
                //Skip directly when there is no modification
                    if (mapToWriteToDisk.containsKey(k)) {<!-- -->
                        Object existingValue = mapToWriteToDisk.get(k);
                        if (existingValue != null & amp; & amp; existingValue.equals(v)) {<!-- -->
                            continue;
                        }
                    }
                    // Otherwise write it to the map that needs to be written to disk
                    mapToWriteToDisk.put(k, v);
                }
//Set the changesMade flag to true
                changesMade = true;
                //If there is a listener
                if (hasListeners) {<!-- -->
                //Write the key value of the key-value pair that needs to be modified into keysModified
                    keysModified.add(k);
                }
            }
//Clear the mModified map
            mModified.clear();
//If there are any changes, they need to be submitted to the disk.
            if (changesMade) {<!-- -->
            //Auto-increment is equivalent to a version number
                mCurrentMemoryStateGeneration + + ;
            }
            memoryStateGeneration = mCurrentMemoryStateGeneration;
        }
    }
    //Return an object. This object describes the relevant information of the data that needs to be written to the disk.
    return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
            listeners, mapToWriteToDisk);
}

Some comments on this method have been written above. This is mainly to encapsulate the editor’s previous operations, such as the putString called before executing commit, into a MemoryCommitResult object, this object is used to describe the relevant information of the data that needs to be written to the disk.

After reading the commitToMemory method, let’s look at the commit method next:

public boolean commit() {<!-- -->
    long startTime = 0;

    if (DEBUG) {<!-- -->
        startTime = System.currentTimeMillis();
    }
//Submit the previous operation to form a submission object
    MemoryCommitResult mcr = commitToMemory();
// Add to the disk write queue of the external class
    SharedPreferencesImpl.this.enqueueDiskWrite(
        mcr, null /* sync write on this thread okay */);
    try {<!-- -->
    //Wait for writing to complete
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {<!-- -->
        return false;
    } finally {<!-- -->
        if (DEBUG) {<!-- -->
            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                     + " committed after " + (System.currentTimeMillis() - startTime)
                     + "ms");
        }
    }
    //Wake up the listener after writing is completed
    notifyListeners(mcr);
    //Return whether writing is successful
    return mcr.writeToDiskResult;
}

The main comments have also been written in the code. The entire process of commit is still easy to understand. The first is to use the commitToMemory method we introduced before Encapsulate it into a submission message, then add it to the SP’s task queue, wait for its writing to complete, and finally return the result.

Next we look at the process of adding to the task queue, specifically SharedPreferencesImpl.this.enqueueDiskWrite( mcr, null ); this sentence:

private void enqueueDiskWrite(final MemoryCommitResult mcr,
                              final Runnable postWriteRunnable) {<!-- -->
    //Determine whether it is submitted synchronously (when postWriteRunnable == null, it means it is submitted synchronously)
    final boolean isFromSyncCommit = (postWriteRunnable == null);
//Package the write task into a Runnable
    final Runnable writeToDiskRunnable = new Runnable() {<!-- -->
            @Override
            public void run() {<!-- -->
                synchronized (mWritingToDiskLock) {<!-- -->
                //Synchronously execute the writeToFile method, that is, write to the disk, specifically the xml file corresponding to each Sp
                    writeToFile(mcr, isFromSyncCommit);
                }
                //The number of tasks being written--
                synchronized (mLock) {<!-- -->
                    mDiskWritesInFlight--;
                }
                //When writing asynchronously, a postWriteRunnable task will be added and executed here
                if (postWriteRunnable != null) {<!-- -->
                    postWriteRunnable.run();
                }
            }
        };

    //When the operation is submitted synchronously
    if (isFromSyncCommit) {<!-- -->
        boolean wasEmpty = false;
        synchronized (mLock) {<!-- -->
            wasEmpty = mDiskWritesInFlight == 1;
        }
        //When only the current task needs to be submitted
        if (wasEmpty) {<!-- -->
        //When the empyt flag is true, directly execute the Runnable we packaged above
            writeToDiskRunnable.run();
            return;
        }
    }
//If there are other tasks before asynchronous or synchronous submission, it will be added to the work queue for execution. The second parameter is shouldDelay
//Flag bit, that is, whether a delay of 100ms is required. You can see that no delay is required when synchronizing and asynchronous when asynchronous.
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

The relevant code logic here has also been marked in the method. The special processing here lies in the synchronous and asynchronous processing. When it is submitted synchronously and the current task is the only task, the current task will be executed directly without going through task queue, otherwise it will be processed through the task queue. There is no need to delay when submitting to the task queue for execution during synchronization, but a 100ms delay is required when submitting asynchronously. Why is there a delay of 100ms? Let’s wait and see the source code of this part.

Apply to submit changes

After looking at synchronous submission, let’s look at asynchronous submission next. First, we follow the above operations on the task queue, followed by the above QueuedWork.queue method:

public static void queue(Runnable work, boolean shouldDelay) {<!-- -->
    Handler handler = getHandler();

    synchronized (sLock) {<!-- -->
        sWork.add(work);

        if (shouldDelay & amp; & sCanDelay) {<!-- -->
            handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
        } else {<!-- -->
            handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
        }
    }
}

You can see that this method is very short. In fact, tasks are submitted through the Handler mechanism. So where is the corresponding Thread? You can see this in the getHandler method:

private static Handler getHandler() {<!-- -->
    synchronized (sLock) {<!-- -->
        if (sHandler == null) {<!-- -->
        //Create a HandlerThread to use as a worker thread
            HandlerThread handlerThread = new HandlerThread("queued-work-looper",
                    Process.THREAD_PRIORITY_FOREGROUND);
            //Start the worker thread
            handlerThread.start();
//Create Handler
            sHandler = new QueuedWorkHandler(handlerThread.getLooper());
        }
        return sHandler;
    }
}

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) {<!-- -->
        //Handle pending tasks
            processPendingWork();
        }
    }
}

Since writing to disk is also a time-consuming operation, SharedPreferences will create a HandlerThread thread as a worker thread when executing a write task, associate it with a Handler, and process the task queue through the Handler. The delay 100ms we mentioned before is specifically reflected through the queue method:

public static void queue(Runnable work, boolean shouldDelay) {<!-- -->
    Handler handler = getHandler();

    synchronized (sLock) {<!-- -->
        sWork.add(work);

        if (shouldDelay & amp; & sCanDelay) {<!-- -->
            handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
        } else {<!-- -->
            handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
        }
    }
}

Here the task is sent to the MessageQueue through the handler. If the shouldDelay and sCanDelay flags are both true, the Handler’s sendMessageDelay method will be passed. The second parameter is the number of milliseconds of the delay. We can see its specific value:

You can see that there is only a 100ms delay.

Okay, let’s get down to business now, let’s look at the source code of the apply method:

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 & amp; & amp; 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);
}

You can see that the entire apply method is almost the same as commit. One of the differences is the mcr.writtenToDiskLatch.await( )This sentence is executed directly in the commit method, which is used to wait for the completion of writing; and the apply method encapsulates this method into A Runnable object is then inserted into the work queue for execution, so it will not cause blocking at the calling site. In addition, the difference we can find in apply is that the addFinisher method is called, which is the same as the specific work queue class QueuedWork Related.

Work queueQueuedWork

The difference between this special work queue and other work queues should be the Finisher queue it holds. Specifically, this queue ensures that the tasks in the queue are certain Will be executed. What does it mean that it must be executed? As we all know, components such as Activity have their own life cycles. If the remaining tasks in the task queue end when their life cycles end, the remaining tasks in the task queue will naturally not be executed. The existence of this queue guarantees that the remaining tasks will be processed. Specifically, we can It can be seen from the comments of the waitToFinish method:

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)

This method will be executed in the Activity’s onPause method to ensure that the tasks in the Finisher queue will be executed.

The process of reading data

In the process of reading data, we take the getString method as an example:

public String getString(String key, @Nullable String defValue) {<!-- -->
    synchronized (mLock) {<!-- -->
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

Obviously, the data is obtained through a map, but before that, an awaitLoadedLocked will be executed. As the name suggests, it is the process of waiting for Sp to read the disk file. We will not go into this process in depth, but after reading The process is also locked. So we can saySharedPerferences is a thread-safe tool.

Summary

Finally, let’s summarize the workflow of SharedPreferences, starting with its creation:

Next is its writing process:

The reading process is very simple and there is no need to write.