Framework 深入理解 Android SharedPreferences

SharedPreferencesAndroid 中用来本地化存储简单 key-value 的工具,在开发过程中经常用来存放一些用户的一些简单设置。 Android 给我们写了一个非常优秀的样板代码来存储本地数据,还是非常有价值来学习下。

源码基于 Android 31

读取数据

我们使用 SP 都是 Context#getSharedPreferences() 来获取 SP 实例,最终都会调用到 ContextImpl#getSharedPreperences(),然后我们也以这个函数为我们分析的入口函数。

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

通过 name 来获取 SP 对应的文件,如果没有获取到就通过 getSharedPreferencesPath() 方法来创建一个文件,最后调用 getSharedPreferences() 方法来获取 SP 对象。

来看看 getSharedPreferencesPath() 创建文件的实现:

@Override
public File getSharedPreferencesPath(String name) {
    return makeFilename(getPreferencesDir(), name + ".xml");
}

@UnsupportedAppUsage
private File getPreferencesDir() {
    synchronized (mSync) {
        if (mPreferencesDir == null) {
            mPreferencesDir = new File(getDataDir(), "shared_prefs");
        }
        return ensurePrivateDirExists(mPreferencesDir);
    }
}

private File makeFilename(File base, String name) {
    if (name.indexOf(File.separatorChar) < 0) {
        final File res = new File(base, name);
        // We report as filesystem access here to give us the best shot at
        // detecting apps that will pass the path down to native code.
        BlockGuard.getVmPolicy().onPathAccess(res.getPath());
        return res;
    }
    throw new IllegalArgumentException(
            "File " + name + " contains a path separator");
}

上面的方法很简单,就是在 /data/data/<pakcage_name>/shared_prefs 目录下面创建一个 SP 的本地文件,文件名称是 name + .xml

getSharedPreferences() 方法获取 SP 对象:

@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);
            // ...
            sp = new SharedPreferencesImpl(file, mode);
            cache.put(file, sp);
            return sp;
        }
    }
        // ...
        return sp;
}

以文件为 key 去查询有没有 SharedPreferencesImpl 对象,如果没有就创建一个。

SharedPreferencesImpl 构造函数:

@UnsupportedAppUsage
SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    mLoaded = false;
    mMap = null;
    mThrowable = null;
    startLoadFromDisk();
}

构造函数非常简单,简单说一下其中重要的成员变量,mFile 就是对应的 SP 本地文件;mBackupFile 备份文件,在写之前都会把之前的文件存储为备份,如果写入失败,后续就继续读这个备份文件,写入成功就删除备份文件,他的名字是源文件加上 .bak 后缀; mLoaded 表示是否已经把本地文件加载到内存中了;mMap 内存中存放的数据;最后执行 startLoadFromDisk() 方法从文件中加载数据。

继续看看关键的 startLoadFromDisk() 方法。

private void startLoadFromDisk() {
    synchronized (mLock) {
        mLoaded = false;
    }
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk();
        }
    }.start();
}

直接新建一个线程调用 loadFromDisk() 方法。

private void loadFromDisk() {
    synchronized (mLock) {
        if (mLoaded) {
            return;
        }
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }

    // Debugging
    if (mFile.exists() && !mFile.canRead()) {
        Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
    }

    Map<String, Object> map = null;
    StructStat stat = null;
    Throwable thrown = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                str = new BufferedInputStream(
                        new FileInputStream(mFile), 16 * 1024);
                map = (Map<String, Object>) XmlUtils.readMapXml(str);
            } catch (Exception e) {
                Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        // An errno exception means the stat failed. Treat as empty/non-existing by
        // ignoring.
    } catch (Throwable t) {
        thrown = t;
    }

    synchronized (mLock) {
        mLoaded = true;
        mThrowable = thrown;

        // It's important that we always signal waiters, even if we'll make
        // them fail with an exception. The try-finally is pretty wide, but
        // better safe than sorry.
        try {
            if (thrown == null) {
                if (map != null) {
                    mMap = map;
                    mStatTimestamp = stat.st_mtim;
                    mStatSize = stat.st_size;
                } else {
                    mMap = new HashMap<>();
                }
            }
            // In case of a thrown exception, we retain the old map. That allows
            // any open editors to commit and store updates.
        } catch (Throwable t) {
            mThrowable = t;
        } finally {
            mLock.notifyAll();
        }
    }
}

刚开始会判断备份文件是否存在,如果存在就表示上次写入的时候失败了,就直接使用备份文件的数据当成新的文件,然后加载。
通过 XmlUtils#readMapXml() 方法读取文件流中的 xml,以 Map 的格式返回,最后存储在 mMap 成员变量中。
最后会通过 mLock.notifyAll() 通知其他还在等待加载完成的方法,比如读取的时候就需要等待加载完成,后续会讲到。

再看看读取数据的方法,我以 getString() 方法作为例子,其他的方法都是大同小异:

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

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

获取前会通过自旋的方式去检查加载的状态,加载成功后直接,从内存中的数据去读取。

写入数据

写入数据的时候需要通过 SharedPreferences#edit() 方法返回一个 Editor 对象,然后通过 Editor 对象进行写,写完了以后调用 commit() 或者 apply() 方法提交,最后写入到本地文件中。

先看看 SharedPreferencesImpl#edit() 方法:

@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 实例返回,EditorImplSharedPreferencesImpl 中的内部类。

我们以 EditorImpl#putString()EditorImpl#remove()EditorImpl#clear() 方法为例子看看写入,删除和清空,其他写入的方法也都是大同小异。

@Override
public Editor putString(String key, @Nullable String value) {
    synchronized (mEditorLock) {
        mModified.put(key, value);
        return this;
    }
}

@Override
public Editor remove(String key) {
    synchronized (mEditorLock) {
        mModified.put(key, this);
        return this;
    }
}

@Override
public Editor clear() {
    synchronized (mEditorLock) {
        mClear = true;
        return this;
    }
}

插入或者修改数据会直接把值放在 mModified 中,删除的时候直接把对应 keyvalue 设置成 EditorImpl 自己,清空数据只是加一个 mClear 变量标识。

我们一同看看提交修改的 commit()apply() 方法:

@Override
public boolean commit() {
    long startTime = 0;

    if (DEBUG) {
        startTime = System.currentTimeMillis();
    }

    MemoryCommitResult mcr = commitToMemory();

    SharedPreferencesImpl.this.enqueueDiskWrite(
        mcr, null /* sync write on this thread okay */);
    try {
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    } finally {
        if (DEBUG) {
            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                    + " committed after " + (System.currentTimeMillis() - startTime)
                    + " ms");
        }
    }
    notifyListeners(mcr);
    return mcr.writeToDiskResult;
}

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

commit()apply() 方法都是大同小异,不同的是 commit() 是同步写入,apply() 是异步写入。他们都会调用 commitToMemory() 方法把 EditorImpl 中修改的数据提交到内存中,然后调用 SharedPreferencesImpl#enqueueDiskWrite() 方法同步到本地文件。只是 commit() 没有传递 Runnable 对象,而 apply() 有传递,在这个 Runnable 对象中会等待修改完成,没有完成的可以在 QueuedWork 中查询对应的 Runnable

看看如何把修改同步到内存中去:

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

这里有一个非常重要的参数 mDiskWritesInFlight 变量,当它大于 0 的时候就表示有其他的写入还没有完成,后续会再讲到这个参数。

首先判断是否要清空数据,如果要清空数据就把以前的数据清空。

然后遍历 EditorImpl 中修改的数据,如果 value 值为空或者为 EditorImpl 对象时就表示要删除这条数据,反之就是更新数据或者插入数据。最后就得到了更新后的数据 mapToWriteToDisk,也就是 mMap 内存中的数据,最后会把更新后等待写入到本地文件中的数据添加到 MemoryCommitResult 中供后续写入到文件中使用。

然后再来看看 SharedPreferences#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);
    }

最终执行写文件的任务是 writeToDiskRunnable,如果是异步调用或者如果有其他提交还没有修改完(异步通过 postWriteRunnable 判断,是否有其他的修改没有完成通过 mDiskWritesInFlight 判断),就会用其他线程异步执行。 反之就在当前线程直接执行。执行写文件的方法是 writeToFile()

@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    // ...
    
    try {
        FileOutputStream str = createFileOutputStream(mFile);
        // ...
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
        
        // ...

        // Writing was successful, delete the backup file if there is one.
        mBackupFile.delete();

        // ...

        mcr.setDiskWriteResult(true, true);
        
        // ...

        return;
    } catch (XmlPullParserException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    } catch (IOException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    }

    // Clean up an unsuccessfully written file
    if (mFile.exists()) {
        if (!mFile.delete()) {
            Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
        }
    }
    mcr.setDiskWriteResult(false, false);
}

我省略了部分代码,留下关键代码,通过 XolUtils.writeMapXml() 方法把更新后的数据写入到文件中,更新成功后会把结果写入到结果中,然后删除备份文件,如果更新失败会删除更新失败的文件。

总结

每个不同 SPname 都对应一个不同的本地文件,每次使用时都会去检查是否有加载到内存中,如果没有加载就会从本地去加载然后保存到内存中(没有回收机制),以后再使用就不用再加载。 如果要执行读写操作的话,也必须阻塞等待加载完成。读取操作性能比较好,是直接读取内存中的数据。写操作需要先更新内存中的数据,然后把更新的数据全部重新再写人本地文件。

根据 SP 的各种特点我们在开发中需要注意以下事项:

  • 同一个 SP 文件存放的数据不宜过大,过大会导致初次加载慢,写入的数据也慢,占用的内存也多,一个 SP 文件存放的数据过大可以考虑根据业务逻辑拆分成多个 SP 文件。如果是存放的单条 key-value 过大,可以考虑自己写一个本地文件缓存,如果是有非常多条的 key-value 也可以考虑使用数据库。
  • 在获取 SP 对象时是不会阻塞线程的,但是在使用它时会有可能会阻塞,因为需要等待本地文件加载完成,所以使用时最好能够在后台线程操作。
  • 写入有通过 commitapply 两种方式提交数据,commit 是同步等待整个 SP 文件写入完成,apply 是异步,他们都有可能造成卡顿,甚至 ANR,虽然 apply 是异步的,但是在某些组件的生命周期中也会检查没有完成的 SPapply 任务,长时间没有完成也会 ANRcommit 方法强烈建议在后台线程中执行,这样就不会导致 ANR


阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: android