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.