其实在Android项目开发中,数据存储是不可防止的,尤其是本地耐久化存储,例如线上日志存储、本地图片存储等,尤其是线上用户行为日志存储,假设存在一再的IO操作,会占用CPU的时刻,导致手机发热,带来一些功用问题。

像在我们手机的设置傍边,我们常常会设置一些选项,例如屏幕亮度、动静巨细等,假设了解一些framework层的源码,我们会发现这些值其实就是存储在Settings的xml文件傍边,假设看过设置的源码,会发现一种我们没用过的Activity – PreferenceActivity,其内部结束原理就是通过SharedPreference结束数据耐久化存储。例如屏幕亮度对应一个key,对应的value值为0-255之间,每次体系重启或许批改值,都会从key中读取最新值或许掩盖之前的值,音量同理。

Android进阶宝典 -- 数据存储优化

1 传统数据存储手段

前面我们在提到设置相关的数据变动时,是选用了sp存储,关于sp存储相信伙伴们并不生疏,在早些年关于一些轻量级的数据存储,一般都是选用sp这种手段。

1.1 SharedPreference

关于sp的用法就不过多赘述,但是关于sp的一些原理性的问题,我们需求了解。

val sp = getSharedPreferences("share", Context.MODE_PRIVATE)
sp.edit().putString("light","255").commit()

sp怎样结束本地数据存储的?

其实了解sp的伙伴们应该都了解,当我们运用sp这个东西的时分,其实会自动帮我们生成一个xml文件,其间存储了我们界说的各种key。

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="light">255</string>
</map>

已然生成了文件,那么sp其实就是运用传统的Java IO进行文件读写,所以针对传统的IO存在的害处,在 Android进阶宝典 — 从IO到NIO机制的演进
这篇文章中详细介绍了IO场景,其实假设在主线程中进行读写操作,Basic IO是会阻塞主线程的,所以这就是sp存储也存在的一个害处,一再地运用sp存储数据,就或许会导致卡顿。

sp的数据更新是怎样结束的?

前面我们提到,已然能存储数据,那么数据是随时会变的,sp是怎样结束数据更新的?我们看前面提到的一个事例,就是设置屏幕亮度,当把屏幕的亮度调暗之后,只会针对sp存储中亮度这个key进行数据批改吗?其实不是的,关于sp来说,它没有增量更新的概念,因为是xml格式的数据结构,所以在更新数据的时分,会把新老数据全部序列化,然后从头掩盖原文件。

即就是文件中100个数据,只需求更新其间一条,也会全量更新,因此在提交的时分不要每次批改之后都提交。

commit和apply的差异

我们在提交批改的时分,一般是有两个方法:commit和apply。

Android进阶宝典 -- 数据存储优化

我们在运用commit的时分,编译器会有提示,建议我们运用apply方法而不是commit,那么两者有什么差异呢?

If you don’t care about the return value and you’re using this from your application’s main thread, consider using apply instead. Returns: Returns true if the new values were successfully written to persistent storage.

在官网关于commit的解说是:假设开发者不关怀回来值,或许说不需求运用这个回来值在主线程中进行处理,那么就可以运用apply来代替。

这句话是什么意思呢?假设我们在sp中存储了一个值,我们必需求保证这个值存储成功之后,才华履行下一步操作,例如跳转到下一级页面,而且下一级页面必需求用到这个值,那么此时就需求运用commit,因为这个是同步的操作,但是就因为这样,假设存在数据量过大的情况,或许会导致ANR。

apply commits its changes to the in-memory SharedPreferences immediately but starts an asynchronous commit to disk and you won’t be notified of any failures.

那么关于apply,官方的解说为:和commit不同的是,apply会将批改先同步提交到内存中,然后通过异步的方法将这个批改写入到磁盘文件,但是关于开发者来说,并不知道这个批改是否成功写入到磁盘缓存中。

所以针对这两种方法,开发者可以根据事务场景自行选择。

getSharedPreferences会不会阻塞主线程

我们知道,在调用getSharedPreferences方法的时分,其实会创建一个xml文件,并存储到缓存傍边,当下次再次调用getSharedPreferences方法的时分,会以name作为key从缓存中获取对应的xml文件,所以我们只需求关怀第一次创建的进程即可。

@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) {
            //创建XML文件
            file = getSharedPreferencesPath(name);
            mSharedPrefsPaths.put(name, file);
        }
    }
    return getSharedPreferences(file, mode);
}

当从缓存中获取文件为空之后,会通过调用getSharedPreferencesPath方法创建文件,并存储到缓存中,留意这儿getSharedPreferences方法是同步的方法,因为加锁了,会同步回来sp方针。

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;
        }
    }
    if ((mode & 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;
}

关于sp方针的获取,其实也是从缓存中获取,假设没有找到,那么就会创建一个SharedPreferencesImpl方针,并回来。

SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    mLoaded = false;
    mMap = null;
    mThrowable = null;
    startLoadFromDisk();
}
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
private void startLoadFromDisk() {
    synchronized (mLock) {
        mLoaded = false;
    }
    //打开子线程结束的
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk();
        }
    }.start();
}

其实实在耗时的操作是读取数据的进程,我们看在内部时通过打开一个子线程结束,所以在getSharedPreferences方法调用的时分,是不会阻塞主线程的

1.2 传统数据耐久化存储方案的害处

通过上面关于sp部分才干的解读,我们大约总结传统的数据耐久化方案存在的害处:

(1)读写方法为Basic IO,会阻塞主线程,数据量过大或许会导致ANR;

(2)功用较差,一再地读写或许会导致卡顿;

(3)不支持增量更新,数据新增或许更新只能全量更新,功率差。

所以,假设想要对数据存储做优化,就需求从以上3点动身,腾讯针对传统数据存储的害处,推出了MMKV组件。

2 MMKV的优化方案

不知道有没有在项目中通过kv彻底代替sp的伙伴,但凡运用过kv的伙伴,都能有激烈的感触,这家伙怎样和sp这么像,调用的方法跟sp也是一样的,其实伙伴们的感触是正确的,因为sp是Google官方推出的,它结束了SharedPreferences接口,而MMKV也是结束了SharedPreferences接口,只不过是内部的逻辑发生了改变。

2.1 IO存储优化

首要,我们先抛开MMKV不谈,假设要我们针对sp进行优化,首要就要处理IO问题,关于传统的Basic IO阻塞问题,在Android进阶宝典 — 从IO到NIO机制的演进中,提到了应对战略,就是NIO。

NIO选用的是轮询查询机制,BIO会一向阻塞到内核结束数据仿制,而NIO则是在仿制结束之前,主线程可以做其他的任务处理,然后防止大数据量读写导致ANR的问题。

我们首要从一个简略的比如中看一下BIO和NIO之间的不同:

object FileCopyTest {
    fun testBio() {
        val startTime = System.currentTimeMillis()
        val fis = FileInputStream(File("/sdcard/test.apk"))
        val fos = FileOutputStream(File("/sdcard/testcopy.apk"))
        var len = 0
        val bytes = ByteArray(2048)
        while (fis.read(bytes).also {
                len = it
            } != -1) {
            fos.write(bytes, 0, len)
        }
        fis.close()
        fos.close()
        Log.d("TAG", "testBio cost time ${System.currentTimeMillis() - startTime}")
    }
}

首要,BIO就是我们常常运用到的FileInputStream、FileOutputStream等IO流,这儿我们是做了一次仿制任务,成果耗时为:

testBio cost time 576

Android进阶宝典 -- 数据存储优化

fun testNio(){
    val startTime = System.currentTimeMillis()
    val fisChannel = FileInputStream(File("/sdcard/test.apk")).channel
    val fosChannel = FileOutputStream(File("/sdcard/testcopy.apk")).channel
    fosChannel.transferFrom(fisChannel,0,fisChannel.size())
    fisChannel.close()
    fosChannel.close()
    Log.d("TAG", "testNio cost time ${System.currentTimeMillis() - startTime}")
}

假设我们选用FileChannel,它是在java.nio.channels包下的类,也是用于文件的读写,但是运行之后耗时为:

testNio cost time 245

我们可以看到,NIO的仿制功率是BIO的2倍之多,为什么功率能进步这么多,就是因为NIO底层选用的零仿制技术。

2.1.1 零仿制技术

什么叫做零仿制技术?其实可以这么了解,没有CPU参与仿制的技术。在文章最初,我们提到了传统的IO存储是需求占用CPU的,一次IO操作占用的CPU时刻很少,但是假设出现一再的IO存储,那么CPU占比就会升高,必然会带来功用问题。

Android进阶宝典 -- 数据存储优化

看上图,当我们想把文件仿制到磁盘中,其实在用户空间是无法结束的,因为终究的处理肯定是内核空间结束,那么内核空间其实提供了一些体系api(syscall),用户空间通过调用这些api,例如write操作,通过传递参数给到内核空间,这个进程中其实并没有对数据做任何的操作,但是却耗费了CPU,虽然时刻很短。

所以我们前面提到的零仿制技术,就是要去掉从用户空间仿制到内核空间这次操作,那么怎样去结束呢?其实我们在用户空间操作的都是虚拟内存,看下图:

Android进阶宝典 -- 数据存储优化
开发者操作的虚拟内存,都是和物理内存中的某块内存存在映射关系的,包含内核空间的虚拟内存,但是详细是哪块内存并不知道,用户空间和内核空间或许存在物理内存映射,也或许不存在。

因为终究虚拟内存都需求映射到物理内存,所以BIO进行磁盘存储的时分,才会需求仿制(CPU copy),从用户空间仿制到内核空间。但是假设用户空间和内核空间都映射到了一块物理内存,见下图:

Android进阶宝典 -- 数据存储优化

其实相当于用户空间的虚拟内存 == 内核空间的虚拟内存,那么用户空间需求仿制的文件,在内核空间也就存在了,这样的话就能削减一次CPU copy,假设了解Binder驱动的伙伴应该了解,mmap就是这么做的。

正是因为削减了一次CPU copy工作,所以整个仿制工作的功率就上来了。

2.1.2 零仿制技术的利益

通过上面的介绍,利益其实就不用再过多赘述了:

(1)削减CPU copy任务,进步读写的功率;

(2)读写操作无需打开线程,直接操作内存,速度很快。

这个点在这儿说明一下,像sp,我们在源码中可以看到,其实读写操作是通过打开一个子线程结束的。我们在开发进程中,假设不是一再的读写操作,其实大部分都是放在了主线程中结束这件事,其实是存在风险的。

那么mmap操作为什么不需求打开线程呢?是因为在结束物理内存映射之后,其实就能拿到对应的内存地址,直接在用户空间操作内存地址,底子就不需求打开线程,因为这个速度太快了

2.1.3 零仿制技术的缺陷

虽然零仿制技术可以在IO上提效,但是也并不是没有任何缺陷的。

首要我们要对一个文件进行mmap映射时,首要需求分配一块物理内存的巨细,一般这个内存的巨细是有明晰的规则束缚的,就是默许情况下为一页(4k),假设需求扩容那么也只能是页的倍数。

也就是说即就是我们的文件很小,缺少一页,但是加载到内存中后也需求将其扩大成1页,举个比如,一个文件巨细为600bytes,但是读到内存中的时分就是4096bytes,相当于有 4096 – 600 = 3496bytes的内存是被浪费了。

还有就是假设一再的调用mmap,那么就会动态请求多个页物理内存,或许会发生许多内存碎片,假设后续无法获取接连的内存空间导致GC发生。

所以针对sp传统的IO读写操作,运用mmap代替BIO,的确是可以进步读写的功率。

2.2 数据结构优化

关于数据结构的优化,sp选用的是存储数据在XML文件中。一般来说,服务端回来的数据格式可以分为json或许xml,但是要我们选择的时分,肯定是会选择json,是因为在序列化和反序列化进程中,xml会跟着数据量的增加,变得越来越杂乱,但是json反而是一个轻量级的数据格式。

因为针对sp中xml数据格式,我们想要优化点在于换一种数据格式代替,MMKV选用的是protobuf数据格式,那么这种数据结构在序列化与反序列化上有什么优势呢?

关于protobuf的编码规则,我不会在这儿进行详细的描绘,它其实是一组二进制数据以串行的方法排列组合在一起,在批改数据的时分,按需批改某个方位的编码,即可结束数据的增量批改,相较于sp的全量更新,mmkv的功率显然是更高的。

关于protobuf的编码原理,我会在独自的一篇文章中介绍,敬请期待吧。

2.3 多进程文件读写

我想伙伴们关于sp的了解应该是比较深化,在前面的源码中我们也可以看到,sp是线程安全的,因为随处可见锁的存在,所以在同一个进程里,只会存在一个线程去写,但读操作是没有束缚的,可以多个线程去读。

但是在多进程的场景下,同步锁的机制其实就是失效的,所以是进程不安全的。那么怎样处理在多进程的场景下,保证只有一个进程进行写操作,这样就保证了进程内是安全的,而单个进程内sp保证了线程安全。

像线程同步机制中存在lock,进程之间同步有锁机制吗,其实也是存在的,就是文件锁flock;这个是在linux底层结束的进程间同步的锁,当一个进程在对文件进行写操作的时分,可以给这个文件加锁,其他进程在读写这个文件的时分,假设没有自动调用flock检测其他进程是否给这个文件加锁,那么就可以直接操作这个文件,所以这儿就会有一个问题,flock并不是像lock这种强制占用的锁,而是比赛两头需求相互检测才华够收效的锁,也被成为是”建议性“锁

假设比赛两头都去运用flock检测文件是否被加锁,这样就能保证进程间文件读写是安全的,那么当一个进程批改了这个文件内容之后,其他进程怎样可以收到批改的告诉呢?

我们知道,当我们下载一个文件的时分,怎样验证这个文件的完整性,其实每一个文件都会有一个签名摘要,通过比较这个文件摘要判别是否发生损坏或许文件丢掉。那么在进程间文件读写的时分,其实每个文件都会有自己的一个crc文件来做完整性的校验,当一个文件被批改之后,crc文件也会被批改。

当另一个进程发起之后,这个crc文件其完结已被加载到内存中,那么就会和拿到的crc文件做比对,假设共同说明文件没有被批改;假设不共同,那么就说明文件被其他的进程批改过了,这样就需求从头加载文件到内存中,MMKV就是这样去判别进程间文件是否发生批改。

其实这就是新兴技术关于传统工作的冲击,在MMKV出现之前,sp是大热的组件,即就是有问题但又没有到不可用的地步,当MMKV出现之后,不管从功用仍是安全性上,都结束了完美的逾越,

public class MMKV implements SharedPreferences, Editor

其实,MMKV也是结束了Google官方的SharedPreferences接口,但内部的结束确是差异化的,关于方案的晋级,其实也是我们每个人需求去考虑的,怎样在现有的基础上,去更好的优化赋能我们的开发环境。

最近刚开通了微信大众号,各位伙伴可以搜索【Layz4Android】,或许扫码重视,每周不守时更新,也可以后台留言感兴趣的专题,给各位伙伴们产出文章。

Android进阶宝典 -- 数据存储优化