Pratique de la technologie de couverture de code Android hautes performances et haute stabilité

Préface

La couverture du code est une méthode de mesure dans les tests logiciels qui reflète la proportion et l'étendue du code testé.

Dans le processus d'itération du logiciel, en plus de prêter attention à la couverture du code pendant le processus de test, la couverture du code lors de l'utilisation par l'utilisateur est également un indicateur très précieux et ne peut être ignoré. Parce qu'avec l'expansion de l'activité et les mises à jour des fonctions, une grande quantité de code obsolète et abandonné est générée. Ces codes sont soit rarement, voire complètement inutilisés, soit "en mauvais état" et manquent de maintenance. Cela n'affecte pas seulement le volume des packages d'application. , mais peut également entraîner des risques pour la stabilité. À l'heure actuelle, il est très important d'être capable de collecter la couverture du code de l'environnement de production, de comprendre l'utilisation du code en ligne et de fournir une base pour le code inutile hors ligne.

Cible

Notre objectif est très clair : selon la configuration du cloud, collecter la portée et la fréquence d'utilisation de chaque catégorie en ligne, la télécharger sur le cloud, la traiter sur la plateforme et fournir des capacités d'affichage de requêtes et de rapports .

8d64ed55b5b677838bb3a6538e486e1d.jpeg

Comme le montre la figure ci-dessus, nous nous attendons à ce que les données de couverture de code puissent être interrogées et affichées intuitivement sur la plate-forme, et puissent être consultées directement en cas de besoin, fournissant ainsi une base pour la prise de décision concernant l'ancien code hors ligne, la planification et l'allocation des ressources, etc. , et finalement fournir aux utilisateurs un package d'installation d'application plus petit pour une meilleure expérience fonctionnelle.

Grâce au centre de contrôle cloud, nous pouvons contrôler s'il faut activer la collecte de couverture, et nous pouvons également ajuster dynamiquement la stratégie de planification et d'allocation des ressources telles que les positions de diamant et les threads dans l'application en fonction de la couverture (fréquence d'utilisation des classes). Parmi elles, la solution de collecte de couverture est la partie la plus importante. Il existe de nombreuses solutions matures dans l'industrie, mais elles ont toutes leurs propres scénarios appropriés. Notre objectif est de collecter du code avec une granularité de classe sans affecter autant l'utilisation des utilisateurs et le fonctionnement de l'application. possible.Utilisez la couverture. La solution de collecte utilisée doit être moins compliquée, simple à mettre en œuvre, prendre en compte à la fois la stabilité et les performances, et en même temps, elle ne doit pas envahir le processus de conditionnement et affecter la taille du colis. Après une exploration approfondie, nous avons développé une ensemble de solutions qui répondent parfaitement à ces exigences.Une toute nouvelle solution.

Comparaison des schémas

Le tableau suivant montre la comparaison de différents indicateurs entre les solutions courantes et les solutions développées par nous-mêmes. Le vert indique mieux.

8cb78dd622aecd2a96c90f661e2c0904.png

Comme le montre le tableau :

Solution Jacoco

Les plus similaires incluent Emma, ​​​​Cobertura, etc. Ils sont tous implémentés via l'instrumentation et peuvent prendre en charge la collecte de toutes les versions et granularités. Cependant, l'instrumentation a un certain impact sur la taille et les performances du package et ne convient pas à une utilisation en ligne à grande échelle. .

Schéma Hook PathClassLoader

Il est simple à mettre en œuvre, ne présente aucune intrusion dans le code source et prend en charge toutes les versions d'Android. Cependant, Hook PathClassLoader a non seulement un impact sur les performances, mais peut même affecter la stabilité de l'application.

Pirater le schéma d'accès ClassTable

Ils peuvent être collectés à la demande et n’ont quasiment aucun impact sur les performances de l’application, mais le piratage peut entraîner des problèmes de compatibilité et est plus complexe à mettre en œuvre.

Plan d'auto-recherche

  • Excellentes performances, prend en charge la collecte à la demande sans compromettre les performances de l'application

  • Il est simple à mettre en œuvre, n’utilise aucune « technologie noire » et présente une excellente stabilité et compatibilité.

  • Prise en charge de la collecte inter-processus et de plug-ins

De la comparaison, nous avons appris que la solution développée par nous-mêmes peut mieux répondre à nos exigences en matière de collecte de couverture de code en ligne, car elle présente non seulement une bonne stabilité, mais également d'excellentes performances et n'aura pratiquement aucun impact sur les utilisateurs. Alors, comment parvient-il à obtenir des performances et une stabilité élevées ? Veuillez consulter l'introduction ci-dessous.

Une introduction

principe

Pour collecter une couverture de code granulaire de classe, vous devez en fait savoir quelles classes sont chargées et utilisées lors de l'exécution de l'application. Dans les applications Java, cela peut être directement interrogé en appelant la méthode findLoadedClass de ClassLoader, mais dans l'application Android, ce n'est pas si simple. La raison en est que le système Android a effectué une telle optimisation :

Afin d'améliorer les performances de démarrage, pour les classes personnalisées par application, c'est-à-dire les classes chargées par PathClassLoader, si vous appelez directement findLoadedClass pour une requête, l'opération de chargement sera effectuée même si la classe n'est pas chargée.

Ce n’est pas ce à quoi nous nous attendions.

Bien que nous ne puissions pas appeler directement la méthode FindLoadedClass pour interroger l'état de chargement de la classe, après des recherches et des analyses approfondies, nous avons constaté que le ClassLoader obtient finalement l'état de chargement de la classe en interrogeant son champ ClassTable. Si nous pouvons également accéder au ClassTable, le problème sera résolu. ? Dans cette optique, nous avons proposé de manière innovante une solution permettant de copier le pointeur ClassTable et d'accéder indirectement à l'état de chargement de la classe via l'API standard .

Cette solution permet intelligemment d'accéder à ClassTable sans piratage ; en même temps, elle contourne parfaitement l'optimisation du chargement des classes dont nous n'avons pas besoin. Elle permet d'acquérir l'état de chargement des classes en seulement quelques lignes de code, ce qui est intelligent et concis. Il présente également les avantages suivants :

  • La vitesse de collecte est plus de 5 fois supérieure à celle des solutions ordinaires , avec d'excellentes performances

  • Utilisez l'API standard pour accéder à ClassTable, avec une excellente compatibilité et stabilité

  • Une seule réflexion est utilisée, pas de "technologie noire", simple et stable

  • N'affecte pas le chargement de la classe et l'exécution de l'application

  • Prend parfaitement en charge la collection de plusieurs processus et plug-ins

Mais il y a une chose à noter :

Le champ ClassTable a été introduit à partir d'Android N, cette méthode n'est donc applicable qu'à Android N et versions ultérieures. Par nécessité et pour des raisons de retour sur investissement, nous ne nous sommes pas adaptés aux versions inférieures à Android N.

Processus de collecte

Sur la base de la solution ci-dessus, nous avons conçu une fonction complète de collecte de couverture de code. Les processus clés sont les suivants :

cd9255384372a8dec3894020860fde49.jpeg

On peut voir que l'ensemble du processus de collecte finale est en série, ce qui est très pratique pour le contrôle des processus et l'intégration des données. Ce qui suit explique les idées de conception :

  • Lors de la collecte, l'application est divisée en deux parties : une partie est constituée des données hôtes utilisées par le processus principal et les sous-processus, et l'autre partie est constituée des données du plug-in.

  • Sur la base de la collection en mode requête, le processus principal, le sous-processus et le plug-in fournissent respectivement des interfaces pour interroger l'état de chargement des classes.

  • Le processus est basé sur la méthode série et est contrôlé par le processus principal. Les interfaces correspondantes sont appelées en séquence pour collecter les données du processus principal, des sous-processus et des plug-ins.

  • Chaque version collecte et rapporte uniquement les données de classe déchargées. Lors de la première collecte, l'ensemble complet des classes est utilisé comme entrée ; pour chaque collecte ultérieure, les classes qui n'ont pas été chargées dans la version précédente sont utilisées comme entrée. Plus de fois de collecte, plus il faut interroger de classes.

  • Le processus principal et les sous-processus sont interrogés dans l'ordre. Les requêtes sont toutes basées sur les classes déchargées restantes après la dernière requête. Par conséquent, les sous-processus ultérieurs nécessitent moins de requêtes. Le même plug-in est également interrogé sur des instances de différents processus. .

  • Comme indiqué ci-dessous:

outside_default.png

  • A la fin de la collection, une copie des données de la classe hôte et N copies des données de la classe du plug-in seront générées (s'il y a N plug-ins). Ces données seront comparées aux résultats de la collecte précédente et les données incrémentielles seront téléchargées sur le service.

  • La plate-forme de services effectue le stockage, le démappage, l'association de modules et d'autres traitements, et enfin les agrège et les affiche sous forme de rapports.

Il est à noter:

  • Les classes utilisées par le processus principal et les sous-processus appartiennent à l'hôte et les résultats de la collecte doivent être fusionnés en une seule donnée. De même, quel que soit le nombre de processus dans lesquels un plug-in est chargé, une seule donnée pour le plug-in devrait être généré à la fin.

  • Lors de la collecte, nous divisons les données en deux parties, ce qui peut améliorer l'efficacité de la collecte et faciliter la désobscurcissement ultérieur ; lorsqu'il est affiché sur la plate-forme, l'affichage combiné est plus significatif.

Gestion des versions

La plupart des codes d'application Android ont été obscurcis et les noms de classe obscurcis varient en fonction de la version. Cela nécessite de gérer les données de couverture en fonction de la version de l'application.

Après avoir géré les données par version, chaque version effacera les données de la version précédente pour éviter toute confusion des données ; une classe spécifique sera enregistrée après l'utilisation de la version actuelle, et la collecte ultérieure de cette version ne questionnera pas à plusieurs reprises son utilisation. Condition.

Lorsque chaque version est collectée pour la première fois,l'ensemble complet des noms de classes d'application doit être utilisé comme entrée. Chaque collection générera une collection de classes inutilisées comme entrée pour la collection suivante. De cette façon, le nombre de classes auxquelles il faut prêter attention dans chaque collection d'une version sera progressivement réduit, ce qui permettra d'éviter les requêtes inutiles et d'améliorer les performances de la collection.

Acquisition de données de nom de classe

Les données de nom de classe peuvent être obtenues de deux manières :

1. Obtenir à partir du package d'installation

Les données de nom de classe dans le package d'installation peuvent être obtenues à partir de PathClassLoader et le plug-in peut être obtenu à partir du BaseDexClassLoader correspondant. Utilisez la méthode suivante :

public static List<String> getClassesFromClassLoader(BaseDexClassLoader classLoader) throws ClassNotFoundException, IllegalAccessException {
    //类名数据位于BaseDexClassLoader.pathList.dexElements.dexFile中,可以通过反射获取


    //先获取pathList字段
    Field pathListF = ReflectUtils.getField("pathList", BaseDexClassLoader.class);
    pathListF.setAccessible(true);
    Object pathList = pathListF.get(classLoader);


    //获取pathList中的dexElements字段
    Field dexElementsF = ReflectUtils.getField("dexElements", Class.forName("dalvik.system.DexPathList"));
    dexElementsF.setAccessible(true);
    Object[] array = (Object[]) dexElementsF.get(pathList);


    //获取dexElements中的dexFile字段
    Field dexFileF = ReflectUtils.getField("dexFile", Class.forName("dalvik.system.DexPathList$Element"));
    dexFileF.setAccessible(true);
    ArrayList<String> classes = new ArrayList<>(256);
    for (int i = 0; i < array.length; i++) {
        //获取dexFile
        DexFile dexFile = (DexFile) dexFileF.get(array[i]);
        //遍历DexFile获取类名数据
        Enumeration<String> enumeration = dexFile.entries();
        while (enumeration.hasMoreElements()) {
            classes.add(enumeration.nextElement());
        }
    }
    return classes;
}

Cette méthode est simple et directe, mais elle chargera simultanément tous les noms de classes de DexFile dans la mémoire. Selon nos tests, toutes les 10 000 classes occupent environ 0,8 Mo de mémoire. Pour les grandes applications comportant des dizaines de milliers de classes, cela dit , il y aura beaucoup de surcharge de mémoire. Vous pouvez donc également envisager la deuxième méthode.

2. Téléchargement dans le cloud

Obtenez les données de nom de classe à partir de la plate-forme de construction et téléchargez-les sur la plate-forme cloud. L'application peut être téléchargée et utilisée en cas de besoin.

Quant à la méthode à choisir, choisissez simplement en fonction du nombre de cours. Lorsque le nombre de classes est particulièrement important, comme dans les scénarios d'applications à grande échelle, il est recommandé d'utiliser la méthode cloud ; pour les applications ou plug-ins ordinaires, vous pouvez les obtenir directement à partir de la classe du package d'installation.

Collection de sous-processus

Pour les classes qui ne sont pas chargées par le processus principal, nous les remettrons à nouveau au processus enfant pour interrogation. Cela nécessite que le sous-processus fournisse une interface de requête prenant en charge les appels inter-processus. Nous avons choisi la solution AIDL qui est simple, fiable et facile à réutiliser pour implémenter cela.

Les étapes spécifiques sont les suivantes :

Définissez l'interface de requête via AIDL et définissez l'action correspondante.Dans la méthode onBind de Service,la classe d'implémentation Binder de l'interface de requête est renvoyée en fonction de l'action pour l'appel à distance.

Dans le même temps, compte tenu du coût élevé des processus croisés, il est sans aucun doute inacceptable d'appeler l'interface de requête une fois pour chaque classe. Nous avons donc pensé à la méthode de requête fichier + batch : utiliser des fichiers comme supports de données, écrire à la fois les classes chargées et les classes déchargées dans des fichiers et transmettre les chemins de fichiers entre les interfaces. Les opérations sur les fichiers peuvent également utiliser BufferedReader et BufferedWriter pour améliorer les performances.

Le processus d'appel est comme indiqué dans la figure :

6fdc20333382ec192af61b60ec951954.jpeg

Les avantages de cette démarche sont également évidents :

  • La collecte d'un processus ne nécessite qu'un seul appel inter-processus et le coût est extrêmement faible

  • Évitez la surcharge de mémoire liée à la sérialisation des données

  • Contourner le problème selon lequel le Big Data ne peut pas être transféré directement entre les processus

  • Le processus de collecte est plus simple et les démarches requises peuvent être collectées sur demande.

  • Facilite le filtrage des données, évite les requêtes répétées des classes chargées et améliore les performances de collecte

Collection de plug-ins

Pour la classe hôte, interrogez simplement le ClassTable correspondant à PathClassLoader.

Les plug-ins sont généralement chargés via BaseDexClassLoader ou ses classes dérivées, et le ClassTable du ClassLoader correspondant doit être interrogé.

Pour les plug-ins utilisés dans les processus enfants, il existe uniquement un appel d'interface inter-processus supplémentaire pour renvoyer les classes chargées et les classes restantes au processus principal pour traitement.

Les étapes de collecte sont les suivantes :

  • Lors de l'interrogation de la classe de sous-processus, la classe de plug-in exécutée dans le processus sera également interrogée et les données seront écrites dans des fichiers divisés par noms de plug-in.

  • La collecte des plug-ins du processus principal est la dernière étape de l'ensemble du processus. A ce moment, les fichiers de données correspondant à chaque plug-in (générés par les sous-processus) seront détectés, fusionnés, et enfin les fichiers de données seront supprimé.

  • Enfin, les fichiers de données de plug-in restants sont traités. Ces fichiers appartiennent aux plug-ins qui s'exécutent uniquement dans le processus enfant.

À ce stade, vous avez obtenu les données de chargement de classe de tous les plug-ins.

Résoudre le mappage

Lorsque nous examinons les données de couverture de code, nous nous attendons à voir le nom de classe d'origine, donc le démappage est la seule voie à suivre.

L'opération de démappage peut être effectuée du côté de l'extrémité ou du côté service. Pour des raisons de sécurité, nous avons choisi le côté service.

Les fichiers de mappage sont générés par le processus de packaging, un pour chaque package d'installation. Notre approche consiste à utiliser un script pour générer des fichiers de mappage pour les classes obscurcies et les classes en texte brut lors de la construction de la plate-forme et de la construction du package officiel. Si nécessaire, le serveur obtient le fichier de mappage correspondant via les informations de version de l'application, décode le nom de classe d'origine et le compare avec le module Faire une association.

Ce qui est finalement affiché sur la plate-forme, ce sont les données de couverture de code une fois le mappage résolu et associées aux modules et plug-ins.

Stockage des données et calcul incrémental

Les données collectées doivent être stockées. Afin de faciliter le calcul des données incrémentielles, nous avons choisi la base de données comme solution de stockage car elle possède des fonctions de déduplication et de tri inhérentes et ses performances sont également bonnes. La méthode spécifique est la suivante :

  • Pour créer une table de données, il vous suffit d'inclure une colonne nommée class. Cette colonne est déclarée comme clé primaire et n'accepte pas les valeurs nulles​​et les doublons.

  • Avant chaque collecte, le nombre de lignes est obtenu. Au cours du processus de collecte, les données de nom de classe chargées sont mises à jour dans la table, permettant à la base de données de terminer automatiquement la déduplication. Une fois la collecte terminée, le nombre de lignes de données est à nouveau obtenu. Le décalage obtenu en soustrayant le nombre de lignes avant la collecte est la partie incrémentielle. Il suffit de télécharger cette partie des données sur le service.

Performances et stabilité

Après nos tests et réglages répétés, le temps de collecte moyen pour les catégories 5w+ est d'environ 0,5 s/heure. Pendant la période de collecte, la mémoire augmente jusqu'à environ 500 Ko et le processeur n'augmente pas de manière significative.

Dans le même temps, il a été vérifié par plusieurs versions d’Amap en ligne, et aucun crash ou ANR associé n’a été trouvé.

autre

Contourner les listes noire et grise

Après Android P, les variables membres de ClassTable ont été officiellement ajoutées aux listes noire et grise. Avant d'utiliser l'accès par réflexion, les restrictions du SDK doivent être contournées. Nous utilisons la méthode de méta-réflexion + exemption de paramétrage. Pour une implémentation spécifique, veuillez vous référer au projet open source FreeReflection sur GitHub. Si vous souhaitez en savoir plus, vous pouvez effectuer une recherche sur Google.

Calendrier et fréquence de collecte

Bien que le processus de collecte soit court et inoffensif, afin de minimiser l'impact sur le fonctionnement de l'application, nous plaçons le travail de collecte dans un sous-thread et choisissons de démarrer l'exécution après que l'application ait quitté l'arrière-plan pendant un certain temps.

Dans le même temps, comme nous n'avons besoin que de connaître la proportion et la situation générale de l'utilisation du code, nous ne pouvons les collecter qu'une seule fois après chaque démarrage à froid.

Les données après plusieurs démarrages à froid par plusieurs utilisateurs sont suffisantes pour refléter l'utilisation réelle du code. Si vous avez besoin de données sur la fréquence d'utilisation pour chaque catégorie, vous pouvez également les obtenir en agrégeant les statistiques côté serveur.

écris à la fin

En tant que méthode de mesure, la couverture du code fournit non seulement une base pour supprimer l'ancien code, mais reflète également la popularité d'une certaine fonction. Elle peut fournir une base pour l'allocation des ressources, les décisions de planification, etc. développement, il manque un outil important.

Notre nouvelle solution est concise mais pas simple. Elle réalise intelligemment une collecte sans piratage. Elle réalise avec élégance une collecte haute performance de la couverture du code de l'environnement de production tout en garantissant une grande stabilité et sans intrusion dans le code source. Elle est déjà trop élevée. la vérification de version est une solution mature, stable et efficace. Je le partage ici dans l'espoir qu'il puisse fournir des références et des idées aux étudiants qui ont le même attrait.

Suivez "Amap Technology" pour en savoir plus

Lecture recommandée

Je suppose que tu aimes

Origine blog.csdn.net/amap_tech/article/details/132572876
conseillé
Classement