SharedPreferences 的commit和apply分析

Posted 碎格子

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SharedPreferences 的commit和apply分析相关的知识,希望对你有一定的参考价值。

之前在做项目开发的时候曾经遇到过一个坑,我们的业务需求是点击相应的国家图标进行国家切换包含汇率、url等的切换,所以当时我们考虑的是切换的时候用SharedPreferences来对存储当前的国家代码,所以我们有了以下的代码:

SharedPreferences.Editor editor = PreferenceUtil.getDefaultPreference(DgApplication.getInstance()).edit();
//value是切换的国家代码
editor.putInt(PreferenceUtil.STRING_COUNTRY_CODE, value);
editor.apply();

在完成切换国家动作之后,为了清除之前的缓存数据更新服务器url,我们选择了像微信退出登录那样重启应用。那个时候我们只开放了两个国家,这个坑在最开始的时候并没有出现问题,后来我们开放了第三个国家、第四个国家…然后突然有一天这个潜在的隐患爆发了,测试人员跑来说为什么我切换到新加坡但是我的界面显示的是马币(马来西亚的货币)?那时候我们才开始注意到这个问题。后来一路追踪排查,发现是这句 editor.apply(); 出的锅。解决办法就是将apply改为commit,也就是 editor.commit(); 就可以了。那么现在来想想,都是提交,为什么用apply会出问题,而commit就可以成功处理呢?他们俩之间到底有什么区别呢?google一下发现大家的结论都是:

  1. apply没有返回值而commit返回boolean表明修改是否提交成功
  2. apply是将修改数据原子提交到内存,而后异步真正提交到硬件磁盘;而commit是同步的提交到硬件磁盘,因此,在多个并发的提交commit的时候,他们会等待正在处理的commit保存到磁盘后在操作,从而降低了效率。而apply只是原子的提交到内存,后面有调用apply的函数的将会直接覆盖前面的内存数据,这样从一定程度上提高了很多效率。
  3. apply方法不会提示任何失败的提示

虽然这些说法都对,但是我们还是追其根源,看看他们各自的源码里都做了什么“手脚”。

apply和commit都是SharedPreferences的内部接口Editor的一个方法,而他们的实现都在SharedPreferencesImpl类里。

首先我们看看在SharedPreferences.Editor接口里commit方法的定义:

 /**
  * Commit your preferences changes back from this Editor to the
  * @link SharedPreferences object it is editing.  This atomically
  * performs the requested modifications, replacing whatever is currently
  * in the SharedPreferences.
  *
  * <p>Note that when two editors are modifying preferences at the same
  * time, the last one to call commit wins.
  *
  * <p>If you don't care about the return value and you're
  * using this from your application's main thread, consider
  * using @link #apply instead.
  *
  * @return Returns true if the new values were successfully written
  * to persistent storage.
  */
  boolean commit();

这里注释的意思大概是这个提交方法会返回一个修改后的结果,会自动执行修改的请求,替换掉当前SharedPreferences里的东西。需要注意的是当两个commit动作同时发生时,最后一个动作会成功。如果不考虑结果并且使用在主线程可以使用apply方法替代,如果成功写入硬件磁盘则会返回true。

综合一下这个注释也就是google上说的:(1)会返回执行结果(2)如果不考虑结果并且是在主线程执行可以考虑apply

下面看看apply方法的定义:

/**
 * <p>Unlike @link #commit, which writes its preferences out
 * to persistent storage synchronously, @link #apply
 * commits its changes to the in-memory
 * @link SharedPreferences immediately but starts an
 * asynchronous commit to disk and you won't be notified of
 * any failures.  If another editor on this
 * @link SharedPreferences does a regular @link #commit
 * while a @link #apply is still outstanding, the
 * @link #commit will block until all async commits are
 * completed as well as the commit itself.
 *
 * <p>As @link SharedPreferences instances are singletons within
 * a process, it's safe to replace any instance of @link #commit with
 * @link #apply if you were already ignoring the return value.
 *
 * <p>You don't need to worry about android component
 * lifecycles and their interaction with <code>apply()</code>
 * writing to disk.  The framework makes sure in-flight disk
 * writes from <code>apply()</code> complete before switching
 * states.
 *
 * <p class='note'>The SharedPreferences.Editor interface
 * isn't expected to be implemented directly.  However, if you
 * previously did implement it and are now getting errors
 * about missing <code>apply()</code>, you can simply call
 * @link #commit from <code>apply()</code>.
 */
 void apply();

略微有点长,大概意思就是apply跟commit不一样的地方是,它使用的是异步而不是同步,它会立即将更改提交到内存,然后异步提交到硬盘,并且如果失败将没有任何提示。

读了以上的注释似乎只了解了他们的区别及简单的工作方式,我们接着去看看具体实现:

SharedPreferencesImpl.EditorImpl.java#commit&apply

public boolean commit() 
    MemoryCommitResult mcr = commitToMemory();
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);
    try 
        mcr.writtenToDiskLatch.await();
     catch (InterruptedException e) 
        return false;
    
    notifyListeners(mcr);
    return mcr.writeToDiskResult;


public void apply() 
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() 
        public void run() 
            try 
                mcr.writtenToDiskLatch.await();
             catch (InterruptedException ignored) 
            
        
    ;
    QueuedWork.add(awaitCommit);
    Runnable postWriteRunnable = new Runnable() 
        public void run() 
            awaitCommit.run();
            QueuedWork.remove(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);

这两个方法都是首先修改内存中缓存的mMap的值,然后将数据写到磁盘中。它们的主要区别是commit会等待写入磁盘后再返回,而apply则在调用写磁盘操作后就直接返回了,但是这时候可能磁盘中数据还没有被修改。

再来看看这两个方法都要调用的commitToMemory

SharedPreferencesImpl.EditorImpl.java#commitToMemory

private MemoryCommitResult commitToMemory() 
    MemoryCommitResult mcr = new MemoryCommitResult();
    synchronized (SharedPreferencesImpl.this) 
        ...
        synchronized (this) 
            ...
            for (Map.Entry<String, Object> e : mModified.entrySet()) 
                String k = e.getKey();
                Object v = e.getValue();
                if (v == this || v == null) 
                    if (!mMap.containsKey(k)) 
                        continue;
                    
                    mMap.remove(k);
                 else 
                    if (mMap.containsKey(k)) 
                        Object existingValue = mMap.get(k);
                        if (existingValue != null && existingValue.equals(v)) 
                            continue;
                        
                    
                    mMap.put(k, v);
                
                ...
            
            mModified.clear();
        
    
    return mcr;

这里使用了HashMap对写入的key进行检索比较,如果之前有同样的key且value不同则用新的valu覆盖旧的value,如果没有存在同样的key则完整写入。需要注意的是这里使用了同步锁住edtor对象,保证了当前数据正确存入。

最后比较重要的就是SP的写磁盘操作。之前介绍的apply和commit都调用了enqueueDiskWrite()方法。以下为其具体实现代码。writeToDiskRunnable中调用writeToFile写文件。如果参数中的postWriteRunable为null,则该Runnable会被同步执行,而如果不为null,则会将该Runnable放入线程池中异步执行。在这里也验证了之前提到的commit和apply的区别。

private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) 
    final Runnable writeToDiskRunnable = new Runnable() 
        public void run() 
            synchronized (mWritingToDiskLock) 
                writeToFile(mcr);
            
            synchronized (SharedPreferencesImpl.this) 
                mDiskWritesInFlight--;
            
            if (postWriteRunnable != null) 
                postWriteRunnable.run();
            
        
    ;
    final boolean isFromSyncCommit = (postWriteRunnable == null);
    // Typical #commit() path with fewer allocations, doing a write on
    // the current thread.
    if (isFromSyncCommit) 
        boolean wasEmpty = false;
        synchronized (SharedPreferencesImpl.this) 
            wasEmpty = mDiskWritesInFlight == 1;
        
        if (wasEmpty) 
            writeToDiskRunnable.run();
            return;
        
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);

看到这里,我想关于commit和apply他们之间的区别已经很明确了,而对于我遇到的坑也有了合理的解释,因为我用的是apply进行存入提交,当我来回切换国家的时候,可能数据还没有正确存入应用就重启了,导致用户看到界面显示的数据错乱。

因此总结一下,如果关心存入结果则使用commit如果不关心存入结果则使用apply。

以上是关于SharedPreferences 的commit和apply分析的主要内容,如果未能解决你的问题,请参考以下文章

SharedPreferences.Editor 的apply()与commit()方法的区别

SharedPreferences.Editor 的apply()与commit()方法的区别

每日一问:谈谈 SharedPreferences 的 apply() 和 commit()

Android SharedPreferences 数据丢失问题

安卓SharedPreferences的使用

SharedPreferences 和线程安全