Analyse sur le principe de la solution de persistance des données de Tencent MMKV

Lorsqu'il s'agit de solutions de stockage de persistance des données, Android propose de nombreuses méthodes. SharedPreference (SP en abrégé) est couramment utilisé dans les projets. Cependant, bien que SP soit simple à utiliser, il présente des défauts :

  • La vitesse d'écriture est lente, en particulier lorsque le thread principal effectue fréquemment des opérations d'écriture, ce qui peut entraîner un décalage ou un ANR ;
  • Les processus croisés ne sont pas pris en charge

Par conséquent, en réponse à cette lacune, nous utilisons souvent d'autres solutions techniques. Par exemple, si nous ne pouvons pas accéder aux données à travers les processus, nous utilisons SQLite pour le stockage des données et fournissons les données au monde extérieur via Provider. Cependant, cette solution pose toujours le problème. de vitesse de réponse lente, ce qui est très difficile. L'ANR peut se produire. Même si les données sont accédées dans un sous-thread, il y aura toujours des problèmes de synchronisation. Jusqu'à l'émergence de MMKV, il semble que les deux problèmes ci-dessus aient été résolus à une fois.

Ainsi, au début de l'article, nous utilisons une petite démo pour vérifier l'efficacité du stockage des données de SharedPreference et MMKV afin de voir l'effet spécifique.

object LocalStorageUtil {

    private const val TAG = "LocalStorageUtil"

    fun testSP(context: Context) {

        val sp = context.getSharedPreferences("spfile", Context.MODE_PRIVATE)
        //记录时间
        val currentTime = System.currentTimeMillis()
        for (index in 0..1000) {
            sp.edit().putInt("$index", index).apply()
        }
        Log.d(TAG, "testSP: cost ${System.currentTimeMillis() - currentTime}")
    }

    fun testMMKV(){
        val mmkv = MMKV.defaultMMKV()
        //记录时间
        val currentTime = System.currentTimeMillis()
        for (index in 0..1000) {
            mmkv.putInt("$index", index).apply()
        }
        Log.d(TAG, "testMMKV: cost ${System.currentTimeMillis() - currentTime}")
    }
}

Jetez un œil au temps pris :

D/LocalStorageUtil: testSP: cost 182
D/LocalStorageUtil: testMMKV: cost 15

Nous voyons que l'efficacité du stockage des données via MMKV est 10 fois supérieure à celle de SP, et cela ne représente que 1 000 temps de stockage consécutifs. À mesure que la quantité de données devient de plus en plus grande, les avantages de MMKV deviennent plus évidents, analysons donc d'abord la source Le code de SharedPreference est utile pour comprendre le code source de MMKV.

1 Analyse du code source de SharedPreference

/**
 * Retrieve and hold the contents of the preferences file 'name', returning
 * a SharedPreferences through which you can retrieve and modify its
 * values.  Only one instance of the SharedPreferences object is returned
 * to any callers for the same name, meaning they will see each other's
 * edits as soon as they are made.
 *
 * <p>This method is thread-safe.
 *
 * <p>If the preferences directory does not already exist, it will be created when this method
 * is called.
 *
 * <p>If a preferences file by this name does not exist, it will be created when you retrieve an
 * editor ({@link SharedPreferences#edit()}) and then commit changes ({@link
 * SharedPreferences.Editor#commit()} or {@link SharedPreferences.Editor#apply()}).
 *
 * @param name Desired preferences file.
 * @param mode Operating mode.
 *
 * @return The single {@link SharedPreferences} instance that can be used
 *         to retrieve and modify the preference values.
 *
 * @see #MODE_PRIVATE
 */
public abstract SharedPreferences getSharedPreferences(String name, @PreferencesMode int mode);

Tout d'abord, avant d'utiliser SP, nous allons d'abord obtenir l'instance SharedPreference en appelant la méthode getSharedPreferences. La valeur de retour finale est l'instance d'interface SharedPreferences et la classe d'implémentation spécifique est SharedPreferencesImpl.

1.1 Analyse de la classe SharedPreferencesImpl

Lors de la première obtention de l'instance SharedPreferences via Context, un nom de fichier sera transmis

ContextImpl #getSharedPreferences

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

Après avoir transmis le nom du fichier, il vérifiera si ce fichier a été créé dans mSharedPrefsPaths. Nous pouvons voir que mSharedPrefsPaths est une carte, qui complète le mappage entre le nom de fichier et le fichier spécifique. Si ce fichier n'existe pas, un fichier sera créé, c'est-à-dire que la méthode getSharedPreferencesPath est appelée, puis stockée dans la collection mSharedPrefsPaths Map.

@Override
public File getSharedPreferencesPath(String name) {
    return makeFilename(getPreferencesDir(), name + ".xml");
}

Enfin, une autre méthode surchargée getSharedPreferences est appelée. Dans cette méthode, le fichier .xml créé est obtenu pour construire la classe SharedPreferencesImpl.

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

Constructeur de SharedPreferencesImpl

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

Comme le montre le constructeur de SharedPreferencesImpl, startLoadFromDisk est appelé pour lire les fichiers du disque à chaque fois que SharedPreferencesImpl est créé. Jetons un coup d'œil à l'implémentation spécifique.

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

À partir du code source, nous pouvons voir qu'un thread nommé SharedPreferencesImpl-load est ouvert pour récupérer les fichiers du disque, et ce via un nouveau Thread. Si l'objet SharedPreferencesImpl est créé plusieurs fois, plusieurs threads seront créés. Cela gaspillera le système. ressources.

SharedPreferencesImpl #loadFromDisk

private void loadFromDisk() {
    // ......
    
    // Debugging
    if (mFile.exists() && !mFile.canRead()) {
        Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
    }

    Map<String, Object> map = null;
    StructStat stat = null;
    Throwable thrown = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            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);
            }
        }
    } catch (ErrnoException e) {
        // An errno exception means the stat failed. Treat as empty/non-existing by
        // ignoring.
    } catch (Throwable t) {
        thrown = t;
    }
   
    synchronized (mLock) {
        mLoaded = true;
        
    // ...... 

}

Dans cette méthode, les données seront lues à partir du fichier via BufferedInputStream (IO) et converties en une structure de données Map. En fait, nous pouvons également savoir en regardant le format des données dans le fichier qu'il s'agit en fait de données clé-valeur. structure.

<int name="801" value="801" />
<int name="802" value="802" />
<int name="803" value="803" />
<int name="804" value="804" />
<int name="805" value="805" />
<int name="806" value="806" />
<int name="807" value="807" />
<int name="808" value="808" />
<int name="809" value="809" />
<int name="1000" value="1000" />

Ensuite, la tâche d'initialisation est terminée. Il y a un problème de synchronisation auquel il faut prêter attention ici, c'est-à-dire que le chargement des données du disque est asynchrone, il y a donc un indicateur mLoaded, qui sera défini sur false lors de l'appel de startLoadFromDisk. Il attendra jusqu'à ce que les données du disque soient chargées. sera défini sur true.

Nous devons donc ici prêter attention à quelques points chronophages :

  • Lors du chargement des données à partir du disque, la totalité des données sera chargée. Par exemple, s'il y a 10 000 éléments de données auparavant, ils seront tous lus, donc la lecture des E/S prendra du temps ;
  • Une fois la lecture des données terminée, l'analyse du nœud XML dom prendra également du temps.

1.2 Analyse de lecture et d'écriture SharedPreference

Nous avons déjà présenté le processus d'initialisation, et l'étape suivante concerne les opérations de lecture et d'écriture. Examinons d'abord les opérations d'écriture ;

sp.edit().putInt("$index", index).apply()

À en juger par l'exemple au début de l'article, l'objet Editor est d'abord obtenu via SharedPreference. En fait, l'objet Editor est obtenu à partir de SharedPreferenceImpl et la classe d'implémentation correspondante est EditorImpl.

SharedPreferenceImpl # EditorImpl

public final class EditorImpl implements Editor {
    private final Object mEditorLock = new Object();

    @GuardedBy("mEditorLock")
    private final Map<String, Object> mModified = new HashMap<>();

    @GuardedBy("mEditorLock")
    private boolean mClear = false;

    // ......
    
    @Override
    public Editor putInt(String key, int value) {
        synchronized (mEditorLock) {
            mModified.put(key, value);
            return this;
        }
    }
    // ......
}

Lorsque la méthode putInt est appelée, elle sera stockée dans le HashMap, puis la méthode apply ou commit pourra être appelée pour l'écrire dans le fichier, mais il existe une différence entre les deux.

EditorImpl # appliquer

@Override
public void apply() {
    final long startTime = System.currentTimeMillis();

    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }

                if (DEBUG && mcr.wasWritten) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " applied after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
        };

    QueuedWork.addFinisher(awaitCommit);

    Runnable postWriteRunnable = new Runnable() {
            @Override
            public void run() {
                awaitCommit.run();
                QueuedWork.removeFinisher(awaitCommit);
            }
        };

    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

    // Okay to notify the listeners before it's hit disk
    // because the listeners should always get the same
    // SharedPreferences instance back, which has the
    // changes reflected in memory.
    notifyListeners(mcr);
}

Grâce au code source, nous voyons que la méthode d'écriture sur le disque lors de l'appel de apply est asynchrone. Lors de l'appel de la méthode enqueueDiskWrite, un objet Runnable est transmis. À ce stade, le thread principal ne sera pas bloqué, mais il n'y a aucun résultat de savoir si l'écriture est réussie.

EditorImpl # commit

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 {
        if (DEBUG) {
            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                    + " committed after " + (System.currentTimeMillis() - startTime)
                    + " ms");
        }
    }
    notifyListeners(mcr);
    return mcr.writeToDiskResult;
}

La méthode de validation écrit les données directement sur le disque. À ce stade, le thread sera bloqué jusqu'à ce que l'écriture des données soit terminée, et le résultat du succès ou de l'échec de l'écriture sera renvoyé. Par conséquent, je pense que les partenaires devraient être capables de distinguer les scénarios spécifiques dans lesquels les deux sont appelés.

Étant donné que les opérations de lecture et d'écriture de SharedPreference sont toujours effectuées via des méthodes d'E/S traditionnelles, il s'agit d'un point qui prend du temps. Les opérations de lecture et d'écriture traditionnelles impliquent une communication entre la couche d'application et le noyau.

La couche application ne lance que des instructions pour lire les données, et les véritables opérations de lecture et d'écriture se trouvent dans l'espace du noyau. Le stockage d'E/S traditionnel est copié deux fois, ce qui est également une opération relativement longue. S'il est remplacé par la technologie zéro copie , alors c'est une excellente stratégie d'optimisation, MMKV le fait, donc ceux qui sont familiers avec la communication Binder et mmap peuvent la comprendre, tandis que ceux qui ne la connaissent pas comprendront les principes à travers cet article.

Principe et utilisation de 2 mmap

Nous avons mentionné plus tôt que lors de l'optimisation du stockage IO traditionnel, nous ne voulons pas réaliser la lecture et l'écriture de fichiers via la planification de l'espace utilisateur et du contexte de l'espace noyau, nous penserons donc que mmap peut réaliser une lecture et une écriture de fichiers sans copie, ce qui est nettement plus efficace que les disques traditionnels. Les E/S doivent être rapides, voyons d'abord comment utiliser la fonction mmap, ce qui peut impliquer des connaissances en C++ et JNI.

2.1 Utilisation de mmap

Définissez d’abord une méthode writeBymmap, qui lit et écrit des fichiers en appelant la fonction mmap dans la couche native.

class NativeLib {

    /**
     * A native method that is implemented by the 'nativelib' native library,
     * which is packaged with this application.
     */
    external fun stringFromJNI(): String
    
    external fun writeBymmap(fileName:String)



    companion object {
        // Used to load the 'nativelib' library on application startup.
        init {
            System.loadLibrary("nativelib")
        }
    }
}

Pour la définition des paramètres de la fonction mmap, nous devons comprendre sa signification.

void* mmap(void* __addr, size_t __size, int __prot, int __flags, int __fd, off_t __offset);
  • _addr : Pointe vers l'adresse de départ de la mémoire à mapper. Elle est généralement définie sur null et est déterminée par le système. Une fois le mappage réussi, cette adresse mémoire sera renvoyée ;
  • _size : mappe la longueur du fichier dans l'espace mémoire ;
  • _port : indicateur de protection de la mémoire, généralement les quatre méthodes suivantes -> PROT_EXEC La zone mappée peut être exécutée PROT_READ La zone mappée peut être lue PROT_WRITE La zone mappée peut être écrite PROT_NONE La zone mappée n'est pas accessible ;
  • _flags : indique si cette zone de mappage peut être partagée par d'autres processus. Si elle est privée, alors seul le processus actuel peut la mapper ; si elle est partagée, alors d'autres processus peuvent également obtenir cette mémoire mappée ;
  • _fd : Le descripteur de fichier à mapper vers la mémoire peut être obtenu via la fonction open. Une fois le stockage terminé, close doit être appelé ;
  • _offset : Le décalage du mappage de fichier, généralement défini sur 0.
extern "C"
JNIEXPORT void JNICALL
Java_com_lay_nativelib_NativeLib_writeBymmap(JNIEnv *env, jobject thiz, jstring file_name) {

    std::string file = env->GetStringUTFChars(file_name, nullptr);
    //获取文件描述符
    int fd = open(file.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    //设置文件大小
    ftruncate(fd, 4 * 1024);
    //调用mmap函数,返回的是物理映射的虚拟内存地址
    int8_t *ptr = static_cast<int8_t *>(mmap(0, 4 * 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd,
                                             0));

    //要写入文件的内容
    std::string data("这里是要写入文件的内容");
    //用户空间可以操作这个虚拟内存地址 
    memcpy(ptr, data.data(), data.size());
}

En appelant la fonction mmap, vous pouvez obtenir l'adresse virtuelle de la mémoire physique mappée par le disque, voir la figure ci-dessous :

Dans l'espace noyau, il y a une zone de mémoire physique mappée avec l'espace disque, et dans l'espace utilisateur, vous pouvez obtenir l'adresse de mémoire virtuelle de cette mémoire physique, c'est-à-dire en appelant la fonction mmap ; puis si vous souhaitez effectuer une opération d'écriture ultérieure, il vous suffit de En exploitant la mémoire virtuelle dans l'espace utilisateur, les données peuvent être écrites sur le disque sans avoir besoin de planification contextuelle entre l'espace utilisateur et l'espace noyau, améliorant ainsi l'efficacité.

Après le test, la méthode writeBymmap de NativeLib() a été appelée et les données ont été écrites dans le fichier.

fun testMmap(fileName: String) {

    //记录时间
    val currentTime = System.currentTimeMillis()
    for (index in 0..1000) {
        NativeLib().writeBymmap(fileName)
    }
    Log.d(TAG, "testMmap: cost ${System.currentTimeMillis() - currentTime}")
}

Nous pouvons le calculer de cette façon, et le résultat final est :

D/LocalStorageUtil: testSP: cost 166
D/LocalStorageUtil: testMmap: cost 16

Nous voyons que l'efficacité est fondamentalement la même que celle de MMKV, mais la méthode d'écriture de fichier mmap que nous avons personnalisée plus tôt présente des défauts : si nous voulons seulement écrire 1 octet de données, mais que nous écrirons finalement 4 Ko de données, cela gaspillera de la mémoire.

2.2 Lecture et écriture de données à travers les processus

Pour la méthode de stockage SharedPreference, elle ne peut pas prendre en charge la lecture et l'écriture de données entre processus. Elle ne peut être stockée que dans un seul processus. Si vous souhaitez obtenir un accès aux données entre processus, c'est en fait très simple. Voir la figure ci-dessous :

Étant donné que le fichier disque est stocké sur la carte SD du téléphone mobile, d'autres processus peuvent également l'obtenir à partir du disque en lisant le fichier, mais cela ne peut éviter de passer du mode noyau au mode utilisateur . Par conséquent, comme le montre la figure ci-dessus, le processus A écrit sur le disque Après les données, le processus B peut également copier une copie des données sur le local via l'adresse de la mémoire virtuelle, complétant ainsi la lecture des données entre processus.

extern "C"
JNIEXPORT jstring JNICALL
Java_com_lay_nativelib_NativeLib_getDataFromDisk(JNIEnv *env, jobject thiz, jstring file_name) {
    std::string file = env->GetStringUTFChars(file_name, nullptr);
    //获取文件描述符
    int fd = open(file.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    //设置文件大小
    ftruncate(fd, 4 * 1024);
    //调用mmap函数,返回的是物理映射的虚拟内存地址
    int8_t *ptr = static_cast<int8_t *>(mmap(0, 4 * 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd,
                                             0));
    //需要一块buffer存储数据
    char *buffer = static_cast<char *>(malloc(100));
    //将物理内存拷贝到buffer
    memcpy(buffer, ptr, 100);
    //取消映射
    munmap(ptr, 4 * 1024);
    close(fd);
    //char 转 jstring
    return env->NewStringUTF(buffer);
}

L'appel spécifique est :

NativeLib().getDataFromDisk("/data/data/com.tal.pad.appmarket/files/NewTextFile.txt").also {
    Log.d("MainActivity", "getDataFromDisk: $it")
}

D/MainActivity : getDataFromDisk : Voici le contenu à écrire dans le fichier

À ce stade, après avoir obtenu l'adresse de mémoire virtuelle de la carte de mémoire physique via mmap, une seule copie (memcpy) est nécessaire pour lire et écrire le fichier et prend en charge l'accès inter-processus, qui est également le principe de base de MMKV.

L'image ci-dessus est une image copiée du site officiel. Elle montre l'efficacité d'écriture en utilisant SharedPreference et MMKV. En fait, la raison pour laquelle MMKV peut améliorer l'efficacité d'écriture des dizaines de fois est due au mappage mémoire de mmap pour éviter le changement d'état du noyau et l'état de l'utilisateur, brisant ainsi le goulot d'étranglement traditionnel des E/S (copie secondaire).À partir du prochain article, nous écrirons manuellement un ensemble de framework MMKV avec nos partenaires pour avoir une compréhension plus approfondie de MMKV et mmap.

Notes d'étude sur Android

Article sur l'optimisation des performances Android : Article sur les principes sous-jacents du framework Android : Article sur le véhicule Android : Notes d'étude sur la sécurité inversée Android : Article audio et vidéo Android : Article sur le compartiment de la famille Jetpack (y compris Compose) : Notes d'analyse du code source OkHttp : Article Kotlin : Article Gradle : Article Flutter : Huit corps de connaissances sur Android : Notes de base sur Android : Questions d'entretien Android des années précédentes : Les dernières questions d'entretien Android en 2023 : Exercices d'entretien pour le poste de développement de véhicules Android : Questions d'entretien audio et vidéo :https://qr18.cn/FVlo89
https://qr18.cn/AQpN4J
https://qr18.cn/F05ZCM
https://qr18.cn/CQ5TcL
https://qr18.cn/Ei3VPD
https://qr18.cn/A0gajp
https://qr18.cn/Cw0pBD
https://qr18.cn/CdjtAF
https://qr18.cn/DzrmMB
https://qr18.cn/DIvKma
https://qr18.cn/CyxarU
https://qr21.cn/CaZQLo
https://qr18.cn/CKV8OZ
https://qr18.cn/CgxrRy
https://qr18.cn/FTlyCJ
https://qr18.cn/AcV6Ap

Je suppose que tu aimes

Origine blog.csdn.net/weixin_61845324/article/details/132986684
conseillé
Classement