Android每周一轮子:SharedPreferences

Java基础

浏览数:28

2020-7-5

AD:资源代下载服务

前言

距离上一期的每周一轮子已经过去了很久了,离开的这段时间,去创业做了产品经理的工作,然后项目都失败了,现在重启开始新的技术之路,前段时间在面试,所以对于基础知识点进行了重新的整理,所以结合着面试的内容,将对Android中的第三方框架还有FrameWork层的内容进行更系统的一个整理。开始第一篇,准备先从一个简单的入手,我们最常见的Android中的最常见的一种数据持久化方式,SharedPreferenced,通过SharePreference我们可以以键值对的形式来进行数据的存取。按照常规书写惯例先从写法入手。

面经快速进入通道
[快手,字节跳动,百度,美团Offer之旅(Android面经分享)](https://juejin.im/post/5e9102…
)

基础使用

SharedPreferences preferences = getSharedPreferences("name", MODE_PRIVATE);
preferences.getString("name", "");
preferences.edit().putString("name", null).apply();
preferences.edit().putString("name", null).commit();

上述是SharedPreferences的一个实现方式,通过指定名称和类型来获取一个SharedPreference,对于类型后面会展开来讲,然后通过get方法可以根据键值来获取相应存取的值,对于数据的写入,通过edit方法后调用相应数据类型的put方法来添加数据,最后调用apply和commit方法来提交数据。那么接下来,我们来跟进一下看SharedPreferences是如何实现读写操作的,还有不同的类型的SharedPreference的差异性在哪里。

SharedPreferences实现

下面是SharedPreferences支持的MODE

  • MODE_PRIVATE

只可以被当前创建的应用读取或者共享userid的应用

  • MODE_WORLD_READABLE

其它应用可以进行读

  • MODE_WORLD_WRITEABLE

其它应用可以读写

上述是SharedPreferences实现的常见三种MODE

安装在设备中的每一个Android包文件(.apk)都会被分配到一个属于自己的统一的Linux用户ID,并且为它创建一个沙箱,以防止影响其他应用程序(或者其他应用程序影响它)。用户ID 在应用程序安装到设备中时被分配,并且在这个设备中保持它的永久性。通过Shared User id,拥有同一个User id的多个APK可以配置成运行在同一个进程中.所以默认就是可以互相访问任意数据. 也可以配置成运行成不同的进程,同时可以访问其他APK的数据目录下的数据库和文件.就像访问本程序的数据一样.

怎么读?

在context中,我们可以通过调用getSharePreferenced方法来获取SharePreferences,那么我们先来看一下其具体实现。

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

首先根据名称从SharedPrefsPaths中进行查找,SharedPrefsPaths是一个ArrayMap,通过我们传递的名称作为键,磁盘存储文件File作为值,当SharedPrefsPaths为null的时候,我们创建一个,然后从中根据name来获取值,得不到值的时候,调用getSharedPreferencesPath来创建File,然后将其存入到SharedPrefsPaths之中,最后再调用getSharedPreferences根据File和Mode来获取SharePreference

SharePrefsPaths是一个ArrayMap通过name作为key,通过File作为value,当找不到File的时候,就会根据name在指定的文件夹下创建一个名为name.xml的文件。然后将其缓存起来。

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);
            if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {
                if (isCredentialProtectedStorage()
                        && !getSystemService(UserManager.class)
                                .isUserUnlockingOrUnlocked(UserHandle.myUserId())) {
                    throw new IllegalStateException("SharedPreferences in credential encrypted "
                            + "storage are not available until after user is unlocked");
                }
            }
            sp = new SharedPreferencesImpl(file, mode);
            cache.put(file, sp);
            return sp;
        }
    }
    ....
    return sp;
}

SharedPreferences的实际获取是通过一个以File为键,以SharedPreferencesImpl为值的ArrayMap中存放的,当Cache中查找不到的时候,则会重新创建。整个SharedPreferences的核心实现就是在SharedPreferencesImpl之中。下面,我们来看一下SharedPreferencesImpl的具体实现。

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

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

在其构造函数中调用startLoadFromDisk来进行数据的加载,此处实现是通过新开线程来实现的。loadFromDisk的核心实现代码如下。

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

通过对于xml的解析得到一个Map,后面就可以通过name对Map进行查询即可得到相应的值。至此,我们已经知道了如何从SharedPreferences进行数据的读取数值了,从本地磁盘读取数值到内存之中的Map,我们查找的时候首先进行Map查找就可以了。当有修改的时候回写到磁盘之中。那么接下来,我们来看一下数据应该如何写回。

怎么写?

对于SharedPreferenced值的写入,这里我们先从commit开始。分析commit之前,我们先看一下edit是如何实现的。

public Editor edit() {
    synchronized (mLock) {
        awaitLoadedLocked();
    }
    return new EditorImpl();
}

首先当调用edit方法来进行写操作的时候,会获取到读锁,来等待读操作完成,因为读操作是在一个子线程中进行,因此需要通过await来进行等待,返回了一个EditorImpl实例,对于其中的相关修改操作,其内部有一个Map来存放要写入和要修改的数据。后面我们调用commit和apply的时候,会先进行内存中数据的修改,然后再进行本地文件的修改。接下来,先看一下commit方法。

@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 {
    }
    notifyListeners(mcr);
    return mcr.writeToDiskResult;
}

commit方法中首先调用了commitToMemory方法,然后调用了enqueueDiskWrite方法来进行数据写入到磁盘。

private MemoryCommitResult commitToMemory() {
    long memoryStateGeneration;
    List<String> keysModified = null;
    Set<OnSharedPreferenceChangeListener> listeners = null;
    Map<String, Object> mapToWriteToDisk;

    synchronized (SharedPreferencesImpl.this.mLock) {

        if (mDiskWritesInFlight > 0) {
            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();
                }
                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, keysModified, listeners,
            mapToWriteToDisk);
}

commitToMemory的实现是将记录的修改的Map,同步修改到最开始从本地加载数据Map中。enqueueDiskWrite方法的实现中核心在于一个runnable,runnable封装了文件写入的封装,当commit调用时,将在当前线程执行,当调用apply的时候则会在在一个子线程的HandlerThread中执行。

final Runnable writeToDiskRunnable = new Runnable() {
        @Override
        public void run() {
            synchronized (mWritingToDiskLock) {
                writeToFile(mcr, isFromSyncCommit);
            }
            synchronized (mLock) {
                mDiskWritesInFlight--;
            }
            if (postWriteRunnable != null) {
                postWriteRunnable.run();
            }
        }
    };

apply的实现中首先将修改写到内存之中,然后再写入到磁盘,commit是在当前线程直接写入,而对于apply则是通过一个HandlerThread来实现写入。对于其中的排队实现,通过的是CountDownLatch来实现的,可以对其进行await阻塞等待,当调用其countDown方法的时候,就会将其写入到磁盘之中。CountDownLatch可以用来进行多个任务的执行等待,如果有一个任务想在三个任务执行完成之后再执行,那么就可以通过CountDownLatch来进行。既然apply是通过一个独立的线程来执行的,那么它会不会阻塞主线程呢?答案是会的,在QueueWork中的waitToFinish方法,该方法会在Activity的onPause的时候被调用,会将其中队列的任务全部执行完成。因此其也是会阻塞主线程的执行。

磁盘文件加载与写入

分析完上面的SharedPreferences的读写过程,首先有一个疑问就是如果我们在进行本地文件向内存中装载的时候,再进行文件的写入应该怎么处理?

synchronized (mLock) {
    awaitLoadedLocked();
}

return new EditorImpl();

在返回EditorImpl实现的时候,首先调用了awaitLoadedLocked,通过该方法实现对于从本地磁盘读锁的等待。只有当本地的文件已经加载到内存之中,才会进行后面的相关写操作。

对于本地磁盘文件的操作上,为了防止在写的过程中发生异常,所以在写入的时候,会先将当前文件做一个备份,然后再进行写操作,如果写成功了,则将备份文件删除,当下次进行读写的时候如果判断到有备份文件,则可以认为上次文件的写入是失败的,读取数据的时候则从备份文件中读取,然后将备份文件重命名。这里在文件操作上借助与备份文件做转化防止数据写入出错的设计还是挺巧妙的。

问题

SharePreference支持多线程吗?

SharePreference是支持进行多线程读写的,可以进行多线程下的读写操作,不会出现数据错乱的问题,内部通过锁来进行控制。对于同一个进程,其中只存在一个SharePreference实例。

SharePreference支持多进程吗?

SharePreference是不支持多进程的,因为对于磁盘中数据的加载只会进行一次,因此当一个进程对数据进行修改之后,是无法体现在另一个进程之中的。

SharePreference中的Mode是如何生效的?

在数据写入完成,通过FileUtils的setPermission来根据Mode为当前文件设置权限,然后在文件读的时候也会进行相应的判断,对于文件的读写权限将会根据写入时写入的mode作为判断依据。

总结

通过对于SharedPreferences的分析,可以看出其大致实现上为在本地磁盘通过xml的方式进行文件的存储,当我们获取一个SharedPreferences的实例的时候,开启线程将本地磁盘的数据读取到内存之中,通过一个Map来存放,然后对其修改的时候,会创建一个新的Map来进行当前修改数据的存取,调用commit和apply的时候,将修改的数据写回内存还有磁盘,同时根据设置的MODE,进行相应的判断。

作者:Jensen95