一篇文章搞定《MMKV原理解析》

前言

兄弟们,这篇文章整整MMKV。
有的兄弟问了,这是什么啊?
嘿嘿,很简单的告诉你能废弃SP的基于mmap内存映射的key-value持久化存储组件。
一样的套路,看看本篇文字结构(ps:对这种组件有时候比较纠结,是先讲使用呢?还是先说原理呢?大家给给意见)
1、MMKV是什么(定义、优点)
2、MMKV的使用
3、SharedPreferences解析(为什么不用SP)
4、MMKV原理解析(为什么用MMKV)

那就按部就班往下整吧!!!

MMKV是什么

什么是持久化存储呢

简单来说,持久化存储就是将数据永久保存在磁盘或其他非易失性存储介质上,以便在程序重新启动或设备重启后可以重新加载和使用。

在移动应用开发中,持久化存储非常重要,因为移动设备的特点是资源有限,且具有临时性。通过持久化存储,应用可以将数据持久保存,即使应用关闭或设备重启,数据也不会丢失。常见的使用场景包括存储用户配置信息、用户登录信息、应用状态、用户生成的内容等。

常见持久化存储

在开发中我们接触到的持久化存储有哪些呢?

  • 1、文件存储:使用IO传输的方式将数据写入文件中。可以借助FileInputStream和FileOutputStream类来进行读写操作。这种方式适用于存储较小的数据量,例如配置文件和日志等。
  • 2、Shared Preferences:使用键值对的方式将数据存储到SharedPreferences文件中。SharedPreferences文件是一个XML文件,可以在应用程序间共享。这种方式通常用于存储应用程序的配置参数和用户偏好设置。
  • 3、SQLite数据库:使用SQLite数据库引擎来存储和管理结构化数据。SQLite是一个嵌入式关系型数据库,可以提供高效的数据存储和查询功能。开发者可以借助Android提供的SQLiteOpenHelper类来创建和管理数据库。
  • 4、DataStore:DataStore是Android Jetpack组件库中的一种持久化数据存储解决方案,从Android Jetpack 1.0.0-alpha06版本开始引入。它提供了一种类型安全、支持协程的方式来存储和读取数据,并且可以与LiveData和Flow配合使用。DataStore支持两种存储格式:Proto DataStore和Preferences DataStore。
  • 5、MMKV:MMKV是基于底层的mmap文件映射技术实现的,具有快速的读写速度和较低的内存占用。MMKV适用于在Android应用中存储较大量的键值对数据。

MMKV定义

1、MMKV的出现是微信团队为了替代SharedPreferences的轻量级存储解决方案
2、是一个类似于SharedPreferences的轻量级存储方案
3、基于 mmap 内存映射的 key-value 存储组件

MMKV优点

1、 高性能:MMKV使用了一些技术手段,如mmap文件映射和跨进程通信的共享内存,以实现更高效的数据存取操作。MMKV的性能比SharedPreferences快数十倍,尤其在读写大量数据时效果更加明显。
2、小存储体积:这是因为MMKV使用了一种更高效的序列化算法,并且将数据存储在二进制文件中,避免了XML解析和序列化的开销。相同数据量情况下,MMKV的存储体积可以减少50%以上。
3、 跨进程共享:MMKV支持多进程间的数据共享,这对于需要在多个进程之间传递数据的应用程序非常有用。MMKV通过共享内存和文件锁定机制来确保跨进程读写数据的一致性和安全性。
4、API简单易用:MMKV提供了简洁、易用的API,使数据存取变得更加方便。您可以使用各种数据类型作为键值,而无需进行烦琐的类型转换。同时,MMKV还提供了诸如数据压缩和加密等额外功能,方便开发者进行更多的数据处理。

MMKV的使用

步骤一:引入MMKV库

首先,在您的Android项目中,打开build.gradle文件。在dependencies中添加以下代码:

implementation 'com.tencent:mmkv:1.2.10'

然后,点击Sync按钮以将库添加到您的项目中。这个步骤确保您可以在代码中使用MMKV库。

步骤二:初始化MMKV

在您的应用程序的入口点(通常是Application类)中添加以下代码,以初始化MMKV:

MMKV.initialize(this)

这个调用将确保MMKV可用于整个应用程序。

步骤三:存储和读取数据

使用MMKV存储和读取数据非常简单。以下是几个常用的方法示例:

//存储数据:
val mmkv = MMKV.defaultMMKV()
mmkv.encode("key", value)
//上述代码将value存储到名为"key"的键中。

//读取数据:
val mmkv = MMKV.defaultMMKV()
val value = mmkv.decodeString("key")
//上述代码将从名为"key"的键中读取存储的值并将其分配给value。
//注意事项:MMKV可以存储各种类型的数据,
//包括String、Int、Float、Double、 ByteArray等。
//您只需要根据需要使用相应的encode和decode方法。

//删除数据:
val mmkv = MMKV.defaultMMKV()
mmkv.remove("key")

步骤四:自定义MMKV路径(可选)

除了默认的路径外,您还可以在初始化时指定自定义的MMKV存储路径。例如:

val mmkvPath = MMKV.initialize(this, "/sdcard/mymmkv")

上述代码将在/sdcard/mymmkv路径下创建MMKV实例。

步骤五:迁移SP的数据(给你一个使用的理由)

//获取SharedPreferences实例:
val sharedPreferences = getSharedPreferences("your_shared_preferences_name", Context.MODE_PRIVATE)

//调用importFromSharedPreferences()进行数据迁移:
val mmkv = MMKV.defaultMMKV()
MMKV.importFromSharedPreferences(sharedPreferences, mmkv)

//可选:删除旧的SharedPreferences
sharedPreferences.edit().clear().apply()

这里将SharedPreferences实例和目标的MMKV实例传递给importFromSharedPreferences()方法即可完成迁移。
这样,SharedPreferences中的数据就会被迁移到MMKV中。请注意,迁移完成后,后续应该使用MMKV来读写数据,而不再使用SharedPreferences。这种方式相对于遍历键值对的方式迁移更简单和高效。

SharedPreferences解析(为什么不用SP)

为啥要使用MMKV,我权威的告诉你为啥:(SP各方面不行呗)
下面对比一下为啥SP不行

SharedPreferences的过程

过程就是下面画到的:
在这里插入图片描述
1、启动App初始化:利用IO读取XML文件(SP数据存放在XML中)
xml:(存储文件通常位于/data/data/包名/shared_prefs/目录下。)

<xml version='1.0' encoding='utf-8' standalone='yes'>
<map>
    <string name="name">user_name</string>
    <int name="age" value="28"/>
    <boolean name="xiaomeng" value="false"/> 
<map>

2、通过反序列化全量的添加到内存map中
核心代码:SharedPreferencesImpl.java

Map map = null;
StructStat stat = null;
try {
    
    
    stat = Os.stat(mFile.getPath());
    if (mFile.canRead()) {
    
    
        BufferedInputStream str = null;
        try {
    
    
            str = new BufferedInputStream(
                    new FileInputStream(mFile), 16*1024);
            //将xml文件转成map
            map = XmlUtils.readMapXml(str);
        } catch (Exception e) {
    
    
            Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
        } finally {
    
    
            IoUtils.closeQuietly(str);
        }
    }
} catch (ErrnoException e) {
    
    
    /* ignore */
}

3、上面全量加入到map中后,通过Map.get(key)0获取Value
源码如下:SharedPreferencesImpl.java

public String getString(String key, @Nullable String defValue) {
    
    
    synchronized (mLock) {
    
    
        //阻塞等待sp将xml读取到内存后再get
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        //如果value为空返回默认值
        return v != null ? v : defValue;
    }
}

SharedPreferences存在的问题

1、sp.get的阻塞问题(会出现ANR)
这是个什么问题呢?我们通过上面的过程知道,sp是通过IO获取文件数据是一个耗时的操作,所以需要在子线程操作。
那么当我们数据量变大时,还没有完成XML解析全量加入Map时。因为子线程初始化,这时候我们去获取,自然是获取不到的,拿SP是怎么防止你拿不到呢?
源码如下:

@Nullable
public String getString(String key, @Nullable String defValue) {
    
    
    synchronized (mLock) {
    
    
        //阻塞等待sp将xml读取到内存后再get
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        //如果value为空返回默认值
        return v != null ? v : defValue;
    }
}

private void awaitLoadedLocked() {
    
    
    ...
    // sp读取完成后会把mLoaded设置为true
    while (!mLoaded) {
    
    
        try {
    
    
            mLock.wait();
        } catch (InterruptedException unused) {
    
    
        }
    }
}

awaitLoadedLocked()这个操作会阻塞,当xml文件读取完成后才会释放锁mLock.notifyAll();
这时候阻塞着,如果我们在主线程中调用了呢? 岂不是就一起在等待,也就是阻塞了。那不就ANR了吗。
2、全量的更新问题
这个是个什么问题呢?可以看到每次更新时,会把map中的数据,从内存中全量的更新到文件中。
兄弟们全量更新属于什么啊?那不是相当于每次都重新保存吗。
还是个IO文件的操作,当文件越来越大,全量保存的代价也就越来越大了。
3、commit和apply提交内容都会ANR

  • commit()方法,会进行同步写,一定存在耗时,不能直接在主线程调用。

他会一样把writeToFile任务加入主线程队列中,如果太大,导致全量的更新过慢就会ANR

源码如下:

public boolean commit() {
    
    
            // 开始排队写
            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;
        }
  • 大家都知道apply方法是异步写,但是也可能造成ANR的问题。下面我们来看apply方法的源码。
public void apply() {
    
    
            // 先将更新写入内存缓存
            final MemoryCommitResult mcr = commitToMemory();
            // 创建一个awaitCommit的runnable,加入到QueuedWork中
            final Runnable awaitCommit = new Runnable() {
    
    
                    @Override
                    public void run() {
    
    
                        try {
    
    
                            // 等待写入完成
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
    
    
                        }
                    }
                };
            // 将awaitCommit加入到QueuedWork中
            QueuedWork.addFinisher(awaitCommit);
            Runnable postWriteRunnable = new Runnable() {
    
    
                    @Override
                    public void run() {
    
    
                        awaitCommit.run();
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };
            // 真正执行sp持久化操作,异步执行
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
            // 虽然还没写入文件,但是内存缓存已经更新了,而listener通常都持有相同的sharedPreference对象,所以可以使用内存缓存中的数据
            notifyListeners(mcr);
        }

可以看到这里确实是在子线程进行的写入操作,但是为什么说apply也会引起ANR呢?
因为在Activity和Service的一些生命周期方法里,都会调用QueuedWork.waitToFinish()方法,这个方法会等待所有子线程写入完成,才会继续进行。主线程等子线程,很容易产生ANR问题。

public static void waitToFinish() {
    
    
       Runnable toFinish;
       //等待所有的任务执行完成
       while ((toFinish = sPendingWorkFinishers.poll()) != null) {
    
    
           toFinish.run();
       }
   }

所以可以看到因为apply引起的ANR日志中会有:ActivityThread的信息出现。

at java.util.concurrent.CountDownLatch.await(CountDownLatch.java:202)
at android.app.SharedPreferencesImpl$EditorImpl$1.run(SharedPreferencesImpl.java:364)
at android.app.QueuedWork.waitToFinish(QueuedWork.java:88)
at android.app.ActivityThread.handleStopActivity(ActivityThread.java:3246)
at android.app.ActivityThread.access$1100(ActivityThread.java:141)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1239)

总结:apply方法虽然是在异步线程写入,但是由于Activity和Service的生命周期会等待所有SharedPreference的写入完成,所以可能引起卡顿和ANR问题。

  • 但是为什么会去等待呢?

当然要等待了:举个例子
如果在Activity的onPause方法中调用apply方法保存数据,在异步线程中的写入操作还没有完成时,Activity被销毁了,那么这部分数据就会丢失。
为了避免这种情况,Android系统在Activity和Service的生命周期方法中会等待所有子线程写入操作完成,然后再继续执行下面的代码。这样可以确保数据的一致性和完整性,避免数据丢失或不一致的问题。
4、不支持多进程

MMKV原理解析

首先MMKV是继承SharedPreferences上层也是同样的在map中进行操作的。
那MMKV是怎么解决了上面SharedPreferences的问题的呢?
1、读写方式:I/O
2、数据格式:xml
3、写入方式:全量更新
那就围绕着SharedPreferences这三个问题来了解MMKV的原理吧。

mmap零拷贝

MMKV的核心在于mmap,所以他的优点就是借用了mmap的优点。(FileChannel是典型的利用零拷贝)
首先了解一些基础知识:
我们经常说的内存是什么?
在工作中我们口中的内存都是虚拟内存:虚拟内存又分为两块,用户空间和内核空间。

  • 用户空间是用户程序代码运行的地方(我们APP运行的内存)
  • 内核空间是内核代码运行的地方,由所有进程共享、进程间有相互隔离的一个共享空间。

1、首先看一下传统的IO是怎么操作内存的呢?

  • 用户空间->内核空间(CPU copy)->虚拟内存(DMA copy:负责将数据于内核传输的)->物理内存(内存映射,虚拟内存和物理内存进行映射)

在这里插入图片描述
2、那mmap是怎么操作内存的呢

  • 首先零拷贝只是没有CPU拷贝的、DMA copy还是有的。
  • 用户空间(直接映射到)->虚拟内存(DMA copy)->物理内存

在这里插入图片描述因为没有CPU的拷贝,所以效率是要提升很多的。

  • 实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。
  • 相当于操作内存就等于操作文件。
  • 相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

3、具体是怎样映射过去的呢?先看看map的函数吧,太深了就不说了(源码在C层)
mmap的函数原型:

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
  • start:映射区的开始地址。设置null即可。
  • length:映射区的长度。传入文件对齐后的大小m_size。
  • prot:期望的内存保护标志,不能与文件的打开模式冲突。设置可读可写。
  • flags:指定映射对象的类型,映射选项和映射页是否可以共享。设置MAP_SHARED表示可进程共享,MMKV之所以可以实现跨进程使用,这里是关键。
  • fd:有效的文件描述词。用上面所打开的m_fd。
  • off_toffset:被映射对象内容的起点。从头开始,比较好理解

MMKV数据存储.defalut文件

上面也讲到了SharedPreferences是以xml文件存放的。
而MMKV是以.defalut保存到指定的目录中的。
下面看一下.defalut中什么样子的:
.defalut(这是一个二进制文件,每个字节代表一个8位的二进制数,可以表示256个不同的值(从0到255))
我们用16进制来打开:
在这里插入图片描述
这。。。。。这是什么呢?
我带大家来解读一下:先看下面的图对照着图来说。
在这里插入图片描述
首先0E(十六进制):总长度为14(十进制)
其次07(十六进制):Key长度是7(十进制)
往后数7个:61 62 63 64 65 66 67(十六进制) :Key为abcdefg
其次01(十六进制):Value的长度是1(十六进制)
往后数1个:01:Value为1
那么就是<abcdefg,1>
以此类推第二个键值对为<x,1>

问题一:那么长度超过255也就是超过一个字节了呢?
答:他是一个变长编码来存储的,可以变长到1-5个字节。这个也叫protocol buffers数据存储格式(也是一种序列化与反序列化的数据格式和Json和XML一样,但是更小)
问题二:这么不易读,为什么还要用呢?
第一点肯定是因为占用空间小,数据更紧凑,都是有效数据。
第二点是他可以增量更新,下面看看他的增量更新

增量更新

增量更新贼简单。
直接增量的写进去就行。为什么呢?
举个例子:
我们要更改那个<x,1>:01,01,78,01为<x,2>:01,01,78,02
我们只需要在后面直接加上就可以。
在这里插入图片描述
那为什么直接加上就可进行修改呢?
大家想想我们读取出来数据是加入到哪里的?
是HashMap啊大哥们,HashMap有什么特性啊。重复的Key就覆盖了啊。那不久相对于更新了吗!!!!
不得不说作者真聪明。
那么有同学问了
问题一:那文件不断追加,文件过大了怎么办啊?
嘿嘿:MMKV除了增量更新还有全量更新呢。
他是利用全量更新来解决这个问题的。看看怎么解决的
1、太多重复的Key导致

  • 去重:利用Map去重后进行全量的更新进去

2、确实需要保存更多的数据

  • 扩容:先扩容->再全量的加入新扩容的内容中

MMKV跨进程

MMKV是一种跨进程键值存储库,其原理是利用Shared Memory映射来实现跨进程数据共享。下面按照步骤详细说明MMKV的原理:

  1. 创建共享内存映射:在进程A中,调用MMKV的初始化方法时,会创建一个共享内存映射区域,该区域会被映射到进程A的地址空间中。
  2. 将数据写入共享内存:在进程A中,当调用MMKV的put方法时,需要将要存储的数据序列化,并写入到共享内存中。MMKV使用B+Tree数据结构来组织数据,因为B+Tree适合高效地进行数据的查询和修改。
  3. 通知其他进程有数据更新:在进程A中,当数据写入共享内存后,会发送一个通知给其他进程,告知它们有新数据更新。进程间通信的方式可以是共享内存区域上的一个信号量或者一个读写锁。
  4. 共享内存读取数据:当进程B收到进程A发出的数据更新通知后,会通过共享内存映射将该共享内存区域映射到进程B的地址空间中。
  5. 从共享内存中读取数据:在进程B中,可以直接从共享内存中读取数据。MMKV通过B+Tree的索引结构,可以快速地定位数据,并进行反序列化操作,将数据转换为可用的格式。
  6. 更新数据时的锁机制:在多进程环境下,多个进程可能会同时尝试修改同一个MMKV实例中的数据。为了保证数据一致性,MMKV使用了一种基于CAS(Compare and Swap)机制的自旋锁来实现数据的并发访问控制。这样可以确保每次修改数据时,只有一个进程可以成功地将数据写入共享内存。

MMKV的劣势

凡事不是绝对好的!!
1、他是同步去存储的,写入大的字符串不如SP、Datastore。为什么说不如呢?
他的存储速度确实快,但是大家注意了,他是同步啊!!! 我们关注APP的卡顿时间指的是什么呢?
是主线程的卡顿时间,所以就是他时间再短,也是阻塞了主线程的时间的。但是SP和Datastore是子线程写入的。
别被他的时间骗了哦!!!

2、还有一个缺点:他是磁盘写入啊兄弟们。 有没有知道会出现什么问题的?
哎呦喂,答对了。断电啊、意外关机啊。那么没写完数据怎么办啊?
能咋办,丢失了呗。没办法。(可以在上层做备份缓存)
但是SP和Datastore是有备份的。

总结

总结就是说一下MMKV什么时候使用呢?
那就先看看有哪些开源的三方框架使用了呢?
常见的:Xlog、xCrash、 Flutter MMKV Logger
可以看到都是日志库在使用,这也是得力于上面提到的优点,让他能进行频繁的进行读写。

所以我们在处理这种频繁读写的需求时候,就可以使用MMKV。比如日志、定位数据、需要在某一时间上传服务器。
还有就是因为MMKV支持跨进程共享,所以当使用跨进程操作这种需要持久化的配置数据,那就要考虑MMKV了

猜你喜欢

转载自blog.csdn.net/weixin_45112340/article/details/131798459