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

1,393 阅读15分钟

其实在Android项目开发中,数据存储是不可避免的,尤其是本地持久化存储,例如线上日志存储、本地图片存储等,尤其是线上用户行为日志存储,如果存在频繁的IO操作,会占用CPU的时间,导致手机发热,带来一些性能问题。

像在我们手机的设置当中,我们经常会设置一些选项,例如屏幕亮度、声音大小等,如果熟悉一些framework层的源码,我们会发现这些值其实就是存储在Settings的xml文件当中,如果看过设置的源码,会发现一种我们没用过的Activity - PreferenceActivity,其内部实现原理就是通过SharedPreference实现数据持久化存储。例如屏幕亮度对应一个key,对应的value值为0-255之间,每次系统重启或者修改值,都会从key中读取最新值或者覆盖之前的值,音量同理。

image.png

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。

image.png

我们在使用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

image.png

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占比就会升高,必然会带来性能问题。

image.png

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

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

image.png 开发者操作的虚拟内存,都是和物理内存中的某块内存存在映射关系的,包括内核空间的虚拟内存,但是具体是哪块内存并不知道,用户空间和内核空间可能存在物理内存映射,也可能不存在。

因为最终虚拟内存都需要映射到物理内存,所以BIO进行磁盘存储的时候,才会需要拷贝(CPU copy),从用户空间拷贝到内核空间。但是如果用户空间和内核空间都映射到了一块物理内存,见下图:

image.png

其实相当于用户空间的虚拟内存 == 内核空间的虚拟内存,那么用户空间需要拷贝的文件,在内核空间也就存在了,这样的话就能减少一次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】,或者扫码关注,每周不定时更新,也可以后台留言感兴趣的专题,给各位伙伴们产出文章。

qrcode_for_gh_948a648a034f_344.jpg