细数SharedPreferences的5大缺陷及ANR原因

2,558 阅读5分钟

我们经常使用的SharedPreferences其实是存在很多缺陷的,主要表现在

  • 占用内存
  • getValue时可能导致ANR
  • 不支持多进程
  • 不支持局部更新
  • commit或apply都可能导致ANR

以下参考安卓源码的基础上,使用大白话和部分代码片段和大家一起探讨分享。

占用内存

final class SharedPreferencesImpl implements SharedPreferences {
    ......
        //构造方法
        SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file);
        mMode = mode;
        mLoaded = false;
        mMap = null;
        mThrowable = null;
        //从磁盘里获取xml里的数据
        startLoadFromDisk();
    }
    
    .....
}

我们都知道Context的上下文实现是依靠ContextImpl这个类,而我们的SharedPreferences的实现是依靠SharedPreferencesImpl类,

ContextImpl.java
    /**
     * Map from package name, to preference name, to cached preferences.
     */
    private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

在我们的ContextImpl类中存在一个静态的ArrayMap对象用于缓存当前packageName下的所有sp文件对象,[感谢@年迈码农纠正]

1647676700854.jpg

但是在这个类里面我们可以看到缓存数组的探空 初始化和赋值,但却没有对数组对象里的数据进行移除或者释放的操作,

由此我们也就可以知道,在我们APP运行的过程中,APP对应包目录下的sp文件都会被缓存到方法区里去, 而这种机制的话会导致很占内存,而且宁愿OOM也不会主动释放内存空间。

getValue的时候可能导致线程阻塞或ANR

在我们的SharedPreferencesImpl构造函数里,会启动一个子线程去加载磁盘文件,把xml文件转换成map对象,如果文件很大或者线程调度没有马上启动这个线程的话,那么这个加载的操作需要一段时间后才能执行完成,

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

而假如我们刚好初始化的时候紧接着去getValue的话,getValue里面又会通过awaitLoadedLocked方法来校验是否要阻塞外部线程,

  private void awaitLoadedLocked() {
        if (!mLoaded) {
            BlockGuard.getThreadPolicy().onReadFromDisk();
        }
        while (!mLoaded) {
            try {
            //如果没有加载完成 就一直持有锁
                mLock.wait();
            } catch (InterruptedException unused) {
            }
        }
        if (mThrowable != null) {
            throw new IllegalStateException(mThrowable);
        }
    }

确保取值操作前一定是执行完成了file文件的加载和转换成功,最后在磁盘加载完成时才会notify操作 把我们外部读取value的线程给唤醒。

在上述的操作场景都是我们APP经常会出现的,同时当我们sp离数据存储量很大的话,那这个磁盘加载并阻塞外部线程的时间会比较大 直接就导致了我们主线程获取sp值的时候直接就芭比Q anr了。

不支持多进程

名义上我们在获取sp实例的时候可以传参支持多进程模式,但这个mode参数也只是起到一个多进程数据同步的作用,

 static void setFilePermissionsFromMode(String name, int mode,
            int extraPermissions) {
        int perms = FileUtils.S_IRUSR|FileUtils.S_IWUSR
            |FileUtils.S_IRGRP|FileUtils.S_IWGRP
            |extraPermissions;
        if ((mode&MODE_WORLD_READABLE) != 0) {
            perms |= FileUtils.S_IROTH;
        }
        if ((mode&MODE_WORLD_WRITEABLE) != 0) {
            perms |= FileUtils.S_IWOTH;
        }
        FileUtils.setPermissions(name, perms, -1, -1);
    }

这里的同步是指访问这个sp实例的时候,会判断当前磁盘文件相对最后一次内存修改是否被改动过,如果是的话就重新加载磁盘文件再同步到缓存上,

  public static int setPermissions(String path, int mode, int uid, int gid) {
        try {
            Os.chmod(path, mode);
        } catch (ErrnoException e) {
            Slog.w(TAG, "Failed to chmod(" + path + "): " + e);
            return e.errno;
        }

        if (uid >= 0 || gid >= 0) {
            try {
                Os.chown(path, uid, gid);
            } catch (ErrnoException e) {
                Slog.w(TAG, "Failed to chown(" + path + "): " + e);
                return e.errno;
            }
        }
        return 0;
    }

但这种同步的作用不大,因为当多进程同时修改sp值,但不同进程里的内存数据也不会实时同步,而且同时修改sp数据也会导致数据丢失和覆盖的可能。

不支持局部更新

apply

public void apply() {
            final long startTime = System.currentTimeMillis();
            final MemoryCommitResult mcr = commitToMemory();
            //这个任务最终在ActivityThread里的 handleStopService  handlePauseActivity handleStopActivity方法里执行
            final Runnable awaitCommit = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }
                    }
                };

            QueuedWork.addFinisher(awaitCommit);

            Runnable postWriteRunnable = new Runnable() {
                    @Override
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };
            // 最终调用QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
            //把这个任务加入到ActivityThread中的QueueWork列表里
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
            // changes reflected in memory.
            notifyListeners(mcr);
        }

我们的同步修改commit方法 和异步修改apply方法都是全量更新,也就是即使我们修改的止损一个键值对,它也会把数据重写写入到磁盘文件中,这样就会导致不必要的内存开销。

commit或apply都可能导致ANR

在commit和apply的时候还有一个更致命的问题就是他们也会导致ANR。 这个主要是因为在调用commit和apply都会执行到一个enqueueDiskWrite操作,这个操作会把当前修改sp内存数据同步到Disk磁盘的任务加入到ActivityThread里的一个任务链表集合中, 那么我们肯定会想这个磁盘同步任务什么时候才会最终完成呢,

其实它是需要等到我们的应用中service在stop的时候,或者activity暂停或停止的时候,才会for循环上面提到的任务链表集合任务,最终完成内存数据到磁盘数据的。 那这样的话会因为有大量的读写同步到磁盘的任务导致activity或者service切换生命周期的时候被阻塞住了,最终导致了ANR。

--》handleStopActivity方法(ActivityThread) --》QueuedWork.waitToFinish() --》 processPendingWork(); 再到下面最终执行磁盘回写任务

for (Runnable w : work) {
                    w.run();
                }

综上,经过这些分析想必我们对SharedPreferences有个更了解的地方。

安卓官方推荐我们可以考虑使用jetpack里的DataStore ,或者可以考虑使用腾讯团队开发的MMKV框架