Android 面试之必问高级知识点

2021-07-21 18:07:24 浏览数 (1)

Android 面试之必问Java基础

Android 面试之必问Android基础知识

1,编译模式

1.1 概念

在Android早期的版本中,应用程序的运行环境是需要依赖Dalvik虚拟机的。不过,在后来的版本(大概是4.x版本),Android的运行环境却换到了 Android Runtime,其处理应用程序执行的方式完全不同于 Dalvik,Dalvik 是依靠一个 Just-In-Time (JIT) 编译器去解释字节码。

不过,Dalvik模式下,开发者编译后的应用代码需要通过一个解释器在用户的设备上运行,这一机制并不高效,但让应用能更容易在不同硬件和架构上运 行。ART 则完全改变了这套做法,在应用安装时就预编译字节码到机器语言,这一机制叫 Ahead-Of-Time (AOT)编译。在移除解释代码这一过程后,应用程序执行效率更高、启动也更快。

1.2 AOT优点

下面是AOT编译方式的一些优点:

1.2.1 预先编译

ART 引入了预先编译机制,可提高应用的性能。ART 还具有比 Dalvik 更严格的安装时验证。在安装时,ART 使用设备自带的 dex2oat 工具来编译应用。该实用工具接受 DEX 文件作为输入,并为目标设备生成经过编译的应用可执行文件,该工具能够顺利编译所有有效的 DEX 文件。

1.2.2 垃圾回收优化

垃圾回收 (GC) 可能有损于应用性能,从而导致显示不稳定、界面响应速度缓慢以及其他问题。ART模式从以下几个方面优化了垃圾回收的策略:

  • 只有一次(而非两次)GC 暂停
  • 在 GC 保持暂停状态期间并行处理
  • 在清理最近分配的短时对象这种特殊情况中,回收器的总 GC 时间更短
  • 优化了垃圾回收的工效,能够更加及时地进行并行垃圾回收,这使得 GC_FOR_ALLOC 事件在典型用例中极为罕见
  • 压缩 GC 以减少后台内存使用和碎片

1.2.3 开发和调试方面的优化

支持采样分析器

一直以来,开发者都使用 Traceview 工具(用于跟踪应用执行情况)作为分析器。虽然 Traceview 可提供有用的信息,但每次方法调用产生的开销会导致 Dalvik 分析结果出现偏差,而且使用该工具明显会影响运行时性能ART 添加了对没有这些限制的专用采样分析器的支持,因而可更准确地了解应用执行情况,而不会明显减慢速度。支持的版本从KitKat (4.4)版本开始,为 Dalvik 的 Traceview 添加了采样支持。

支持更多调试功能

ART 支持许多新的调试选项,特别是与监控和垃圾回收相关的功能。例如,查看堆栈跟踪中保留了哪些锁,然后跳转到持有锁的线程;询问指定类的当前活动的实例数、请求查看实例,以及查看使对象保持有效状态的参考;过滤特定实例的事件(如断点)等。

优化了异常和崩溃报告中的诊断详细信息

当发生运行时异常时,ART 会为您提供尽可能多的上下文和详细信息。ART 会提供 java.lang.ClassCastException、java.lang.ClassNotFoundException 和 java.lang.NullPointerException 的更多异常详细信息(较高版本的 Dalvik 会提供 java.lang.ArrayIndexOutOfBoundsException 和 java.lang.ArrayStoreException 的更多异常详细信息,这些信息现在包括数组大小和越界偏移量;ART 也提供这类信息)。

1.3 垃圾回收

ART 提供了多个不同的 GC 方案,这些方案运行着不同垃圾回收器,默认的GC方案是 CMS(并发标记清除),主要使用粘性 CMS 和部分 CMS。粘性 CMS 是 ART 的不移动分代垃圾回收器。它仅扫描堆中自上次 GC 后修改的部分,并且只能回收自上次 GC 后分配的对象。除 CMS 方案外,当应用将进程状态更改为察觉不到卡顿的进程状态(例如,后台或缓存)时,ART 将执行堆压缩。

除了新的垃圾回收器之外,ART 还引入了一种基于位图的新内存分配程序,称为 RosAlloc(插槽运行分配器)。此新分配器具有分片锁,当分配规模较小时可添加线程的本地缓冲区,因而性能优于 DlMalloc(内存分配器)。

内存分配器的相关知识可以参考:内存分配器

同时,与 Dalvik 相比,ART的 CMS垃圾回收也带来了其他方面的改善,如下:

  • 与 Dalvik 相比,暂停次数从 2 次减少到 1 次。Dalvik 的第一次暂停主要是为了进行根标记,即在 ART 中进行并发标记,让线程标记自己的根,然后马上恢复运行。
  • 与 Dalvik 类似,ART GC 在清除过程开始之前也会暂停 1 次。两者在这方面的主要差异在于:在此暂停期间,某些 Dalvik 环节在 ART 中并发进行。这些环节包括 java.lang.ref.Reference 处理、系统弱清除(例如,jni 弱全局等)、重新标记非线程根和卡片预清理。在 ART 暂停期间仍进行的阶段包括扫描脏卡片以及重新标记线程根,这些操作有助于缩短暂停时间。
  • 相对于 Dalvik,ART GC 改进的最后一个方面是粘性 CMS 回收器增加了 GC 吞吐量。不同于普通的分代 GC,粘性 CMS 不移动。系统会将年轻对象保存在一个分配堆栈(基本上是 java.lang.Object 数组)中,而非为其设置一个专属区域。这样可以避免移动所需的对象以维持低暂停次数,但缺点是容易在堆栈中加入大量复杂对象图像而使堆栈变长。

ART GC 与 Dalvik 的另一个主要区别在于 ART GC 引入了移动垃圾回收器。使用移动 GC 的目的在于通过堆压缩来减少后台应用使用的内存。目前,触发堆压缩的事件是 ActivityManager 进程状态的改变。当应用转到后台运行时,它会通知 ART 已进入不再“感知”卡顿的进程状态。此时 ART 会进行一些操作(例如,压缩和监视器压缩),从而导致应用线程长时间暂停。

目前,Android的ART正在使用的两个移动 GC 是同构空间压缩和半空间压缩,它们的区别如下:

  • 半空间压缩:将对象在两个紧密排列的碰撞指针空间之间进行移动。这种移动 GC 适用于小内存设备,因为它可以比同构空间压缩稍微多节省一点内存,额外节省出的空间主要来自紧密排列的对象,这样可以避免 RosAlloc/DlMalloc 分配器占用开销。
  • 同构空间压缩通过将对象从一个 RosAlloc 空间复制到另一个 RosAlloc 空间来实现。这有助于通过减少堆碎片来减少内存使用量。这是目前非低内存设备的默认压缩模式。相比半空间压缩,同构空间压缩的主要优势在于应用从后台切换到前台时无需进行堆转换。

2,类加载器

2.1 类加载器分类

目前,Android的类加载器从下到上主要分为BootstrapClassLoader(根类加载器)、 ExtensionClassLoader (扩展类加载器)和 AppClassLoader(应用类加载器)三种。

  • 根类加载器:该加载器没有父加载器。它负责加载虚拟机的核心类库,如java.lang.*等。例如java.lang.Object就是由根类加载器加载的。根类加载器从系统属性sun.boot.class.path所指定的目录中加载类库。根类加载器的实现依赖于底层操作系统,属于虚拟机的实现的一部分,它并没有继承java.lang.ClassLoader类。
  • 扩展类加载器:它的父加载器为根类加载器。它从java.ext.dirs系统属性所指定的目录中加载类库,或者从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库,如果把用户创建的JAR文件放在这个目录下,也会自动由扩展类加载器加载。扩展类加载器是纯Java类,是java.lang.ClassLoader类的子类。
  • 系统类加载器:也称为应用类加载器,它的父加载器为扩展类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中加载类,它是用户自定义的类加载器的默认父加载器。系统类加载器是纯Java类,是java.lang.ClassLoader类的子类。 父子加载器并非继承关系,也就是说子加载器不一定是继承了父加载器。

2.2 双亲委托模式

所谓双亲委托模式,指的是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子 ClassLoader 再加载一次。如果不使用这种委托模式,那我们就可以随时使用自定义的类来动态替代一些核心的类,存在非常大的安全隐患。

举个例子,事实上,java.lang.String这个类并不会被我们自定义的classloader加载,而是由bootstrap classloader进行加载,为什么会这样?实际上这就是双亲委托模式的原因,因为在任何一个自定义ClassLoader加载一个类之前,它都会先 委托它的父亲ClassLoader进行加载,只有当父亲ClassLoader无法加载成功后,才会由自己加载。

2.3 Android的类加载器

下面是Android类加载器的模型图:

在这里插入图片描述在这里插入图片描述

下面看一下DexClassLoader,DexClassLoader 重载了 findClass 方法,在加载类时会调用其内部的 DexPathList 去加载。DexPathList 是在构造 DexClassLoader 时生成的,其内部包含了 DexFile,涉及的源码如下。

代码语言:txt复制
···
public Class findClass(String name) {
    for (Element element : dexElements) {
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext);
            if (clazz != null) {
                return clazz;
            }
        }
    }
    return null;
}
···

类加载器更多的内容,可以参考:android 类加载器双亲委托模式

3,Android Hook

所谓Hook,就是在程序执行的过程中去截取其中的某段信息,示意图如下。

说到说到

Android的Hook大体的流程可以分为如下几步:

1、根据需求确定需要 hook 的对象

2、寻找要hook的对象的持有者,拿到需要 hook 的对象

3、定义“要 hook 的对象”的代理类,并且创建该类的对象

4、使用上一步创建出来的对象,替换掉要 hook 的对象

下面是一段简单的Hook的示例代码,用到了Java的反射机制。

代码语言:txt复制
@SuppressLint({"DiscouragedPrivateApi", "PrivateApi"})
public static void hook(Context context, final View view) {//
    try {
        // 反射执行View类的getListenerInfo()方法,拿到v的mListenerInfo对象,这个对象就是点击事件的持有者
        Method method = View.class.getDeclaredMethod("getListenerInfo");
        method.setAccessible(true);//由于getListenerInfo()方法并不是public的,所以要加这个代码来保证访问权限
        Object mListenerInfo = method.invoke(view);//这里拿到的就是mListenerInfo对象,也就是点击事件的持有者

        // 要从这里面拿到当前的点击事件对象
        Class<?> listenerInfoClz = Class.forName("android.view.View$ListenerInfo");// 这是内部类的表示方法
        Field field = listenerInfoClz.getDeclaredField("mOnClickListener");
        final View.OnClickListener onClickListenerInstance = (View.OnClickListener) field.get(mListenerInfo);//取得真实的mOnClickListener对象

        // 2. 创建我们自己的点击事件代理类
        //   方式1:自己创建代理类
        //   ProxyOnClickListener proxyOnClickListener = new ProxyOnClickListener(onClickListenerInstance);
        //   方式2:由于View.OnClickListener是一个接口,所以可以直接用动态代理模式
        // Proxy.newProxyInstance的3个参数依次分别是:
        // 本地的类加载器;
        // 代理类的对象所继承的接口(用Class数组表示,支持多个接口)
        // 代理类的实际逻辑,封装在new出来的InvocationHandler内
        Object proxyOnClickListener = Proxy.newProxyInstance(context.getClass().getClassLoader(), new Class[]{View.OnClickListener.class}, new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                Log.d("HookSetOnClickListener", "点击事件被hook到了");//加入自己的逻辑
                return method.invoke(onClickListenerInstance, args);//执行被代理的对象的逻辑
            }
        });
        // 3. 用我们自己的点击事件代理类,设置到"持有者"中
        field.set(mListenerInfo, proxyOnClickListener);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

// 自定义代理类
static class ProxyOnClickListener implements View.OnClickListener {
    View.OnClickListener oriLis;

    public ProxyOnClickListener(View.OnClickListener oriLis) {
        this.oriLis = oriLis;
    }

    @Override
    public void onClick(View v) {
        Log.d("HookSetOnClickListener", "点击事件被hook到了");
        if (oriLis != null) {
            oriLis.onClick(v);
        }
    }
}

而在Android开发中,想要实现Hook,肯定是没有这么简单的,我们需要借助一些Hook框架,比如Xposed、Cydia Substrate、Legend等。

参考资料:Android Hook机制

4,代码混淆

4.1 Proguard

众所周知,Java代码是非常容易反编译的,为了更好的保护Java源代码,我们往往会对编译好的Class类文件进行混淆处理。而ProGuard就是一个混淆代码的开源项目。它的主要作用就是混淆,当然它还能对字节码进行缩减体积、优化等,但那些对于我们来说都算是次要的功能。

具体来说,ProGuard具有如下功能:

  • 压缩(Shrink): 检测和删除没有使用的类,字段,方法和特性。
  • 优化(Optimize) : 分析和优化Java字节码。
  • 混淆(Obfuscate): 使用简短的无意义的名称,对类,字段和方法进行重命名。

在Android开发中,开启混淆需要将app/build.gradle文件下的minifyEnabled属性设置为true,如下所示。

代码语言:txt复制
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

proguard-android.txt是Android提供的默认混淆配置文件,我们需要的混淆的规则都放在这个文件中。

4.2 混淆规则

混淆命令

  • keep:保留类和类中的成员,防止被混淆或移除
  • keepnames:保留类和类中的成员,防止被混淆,成员没有被引用会被移除
  • keepclassmembers:只保留类中的成员,防止被混淆或移除
  • keepclassmembernames:只保留类中的成员,防止被混淆,成员没有引用会被移除
  • keepclasseswithmembers:保留类和类中的成员,防止被混淆或移除,保留指明的成员
  • keepclasseswithmembernames:保留类和类中的成员,防止被混淆,保留指明的成员,成员没有引用会被移除

混淆通配符

  • <field>:匹配类中的所有字段
  • <method>:匹配类中所有的方法
  • <init>:匹配类中所有的构造函数
  • *: 匹配任意长度字符,不包含包名分隔符(.)
  • **: 匹配任意长度字符,包含包名分隔符(.)
  • ***: 匹配任意参数类型

keep的规则的格式如下:

代码语言:txt复制
[keep命令] [类] {
		[成员]
}

4.3 混淆模版

ProGuard中有些公共的模版是可以复用的,比如压缩比、大小写混合和一些系统提供的Activity、Service不能混淆等。

代码语言:txt复制
# 代码混淆压缩比,在 0~7 之间,默认为 5,一般不做修改
-optimizationpasses 5

# 混合时不使用大小写混合,混合后的类名为小写
-dontusemixedcaseclassnames

# 指定不去忽略非公共库的类
-dontskipnonpubliclibraryclasses

# 这句话能够使我们的项目混淆后产生映射文件
# 包含有类名->混淆后类名的映射关系
-verbose

# 指定不去忽略非公共库的类成员
-dontskipnonpubliclibraryclassmembers

# 不做预校验,preverify 是 proguard 的四个步骤之一,Android 不需要 preverify,去掉这一步能够加快混淆速度。
-dontpreverify

# 保留 Annotation 不混淆
-keepattributes *Annotation*,InnerClasses

# 避免混淆泛型
-keepattributes Signature

# 抛出异常时保留代码行号
-keepattributes SourceFile,LineNumberTable

# 指定混淆是采用的算法,后面的参数是一个过滤器
# 这个过滤器是谷歌推荐的算法,一般不做更改
-optimizations !code/simplification/cast,!field/*,!class/merging/*


#############################################
#
# Android开发中一些需要保留的公共部分
#
#############################################

# 保留我们使用的四大组件,自定义的 Application 等等这些类不被混淆
# 因为这些子类都有可能被外部调用
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Appliction
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class * extends android.view.View
-keep public class com.android.vending.licensing.ILicensingService


# 保留 support 下的所有类及其内部类
-keep class android.support.** { *; }

# 保留继承的
-keep public class * extends android.support.v4.**
-keep public class * extends android.support.v7.**
-keep public class * extends android.support.annotation.**

# 保留 R 下面的资源
-keep class **.R$* { *; }

# 保留本地 native 方法不被混淆
-keepclasseswithmembernames class * {
    native <methods>;
}

# 保留在 Activity 中的方法参数是view的方法,
# 这样以来我们在 layout 中写的 onClick 就不会被影响
-keepclassmembers class * extends android.app.Activity {
    public void *(android.view.View);
}

# 保留枚举类不被混淆
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# 保留我们自定义控件(继承自 View)不被混淆
-keep public class * extends android.view.View {
    *** get*();
    void set*(***);
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
}

# 保留 Parcelable 序列化类不被混淆
-keep class * implements android.os.Parcelable {
    public static final android.os.Parcelable$Creator *;
}

# 保留 Serializable 序列化的类不被混淆
-keepnames class * implements java.io.Serializable
-keepclassmembers class * implements java.io.Serializable {
    static final long serialVersionUID;
    private static final java.io.ObjectStreamField[] serialPersistentFields;
    !static !transient <fields>;
    !private <fields>;
    !private <methods>;
    private void writeObject(java.io.ObjectOutputStream);
    private void readObject(java.io.ObjectInputStream);
    java.lang.Object writeReplace();
    java.lang.Object readResolve();
}

# 对于带有回调函数的 onXXEvent、**On*Listener 的,不能被混淆
-keepclassmembers class * {
    void *(**On*Event);
    void *(**On*Listener);
}

# webView 处理,项目中没有使用到 webView 忽略即可
-keepclassmembers class fqcn.of.javascript.interface.for.webview {
    public *;
}
-keepclassmembers class * extends android.webkit.webViewClient {
    public void *(android.webkit.WebView, java.lang.String, android.graphics.Bitmap);
    public boolean *(android.webkit.WebView, java.lang.String);
}
-keepclassmembers class * extends android.webkit.webViewClient {
    public void *(android.webkit.webView, java.lang.String);
}

# js
-keepattributes JavascriptInterface
-keep class android.webkit.JavascriptInterface { *; }
-keepclassmembers class * {
    @android.webkit.JavascriptInterface <methods>;
}

# @Keep
-keep,allowobfuscation @interface android.support.annotation.Keep
-keep @android.support.annotation.Keep class *
-keepclassmembers class * {
    @android.support.annotation.Keep *;
}

如果是aar这种插件,可以在aar的build.gralde中添加如下混淆配置。

代码语言:txt复制
android {
    ···
    defaultConfig {
        ···
        consumerProguardFile 'proguard-rules.pro'
    }
    ···
}

5,NDK

如果要问Android的高级开发知识,那么NDK肯定是必问的。那么什么的NDK,NDK 全称是 Native Development Kit,是一组可以让开发者在 Android 应用中使用C/C 的工具。通常,NDK可以用在如下的场景中:

  • 从设备获取更好的性能以用于计算密集型应用,例如游戏或物理模拟。
  • 重复使用自己或其他开发者的 C/C 库,便利于跨平台。
  • NDK 集成了譬如 OpenSL、Vulkan 等 API 规范的特定实现,以实现在 Java 层无法做到的功能,如音视频开发、渲染。
  • 增加反编译难度。

5.1, JNI基础

JNI即java native interface,是Java和Native代码进行交互的接口。

5.1.1 JNI 访问 Java 对象方法

假如,有如下一个Java类,代码如下。

代码语言:txt复制
package com.xzh.jni;

public class MyJob {
    public static String JOB_STRING = "my_job";
    private int jobId;

    public MyJob(int jobId) {
        this.jobId = jobId;
    }

    public int getJobId() {
        return jobId;
    }
}

然后,在cpp目录下,新建native_lib.cpp,添加对应的native实现。

代码语言:txt复制
#include <jni.h>

extern "C"
JNIEXPORT jint JNICALL
Java_com_xzh_jni_MainActivity_getJobId(JNIEnv *env, jobject thiz, jobject job) {

    // 根据实例获取 class 对象
    jclass jobClz = env->GetObjectClass(job);
    // 根据类名获取 class 对象
    jclass jobClz = env->FindClass("com/xzh/jni/MyJob");

    // 获取属性 id
    jfieldID fieldId = env->GetFieldID(jobClz, "jobId", "I");
    // 获取静态属性 id
    jfieldID sFieldId = env->GetStaticFieldID(jobClz, "JOB_STRING", "Ljava/lang/String;");

    // 获取方法 id
    jmethodID methodId = env->GetMethodID(jobClz, "getJobId", "()I");
    // 获取构造方法 id
    jmethodID  initMethodId = env->GetMethodID(jobClz, "<init>", "(I)V");

    // 根据对象属性 id 获取该属性值
    jint id = env->GetIntField(job, fieldId);
    // 根据对象方法 id 调用该方法
    jint id = env->CallIntMethod(job, methodId);

    // 创建新的对象
    jobject newJob = env->NewObject(jobClz, initMethodId, 10);
    return id;
}

5.2 NDK开发

5.2.1 基本流程

首先,在 Java代码中声明 Native 方法,如下所示。

代码语言:txt复制
public class MainActivity extends AppCompatActivity {

    // Used to load the 'native-lib' library on application startup.
    static {
        System.loadLibrary("native-lib");
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Log.d("MainActivity", stringFromJNI());
    }
    private native String stringFromJNI();
}

然后,新建一个 cpp 目录,并且新建一个名为native-lib.cpp的cpp 文件,实现相关方法。

代码语言:txt复制
#include <jni.h>

extern "C" JNIEXPORT jstring JNICALL
Java_com_xzh_jni_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C  ";
    return env->NewStringUTF(hello.c_str());
}

cpp文件遵循如下的规则:

  • 函数名的格式遵循遵循如下规则:Java包名类名_方法名。
  • extern "C" 指定采用 C 语言的命名风格来编译,否则由于 C 与 C 风格不同,导致链接时无法找到具体的函数
  • JNIEnv*:表示一个指向 JNI 环境的指针,可以通过他来访问 JNI 提供的接口方法
  • jobject:表示 java 对象中的 this
  • JNIEXPORT 和 JNICALL:JNI 所定义的宏,可以在 jni.h 头文件中查找到

System.loadLibrary()的代码位于java/lang/System.java文件中,源码如下:

代码语言:txt复制
@CallerSensitive
public static void load(String filename) {
    Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}

5.3 CMake 构建 NDK

CMake 是一个开源的跨平台工具系列,旨在构建、测试和打包软件,从 Android Studio 2.2 开始,Android Sudio 默认地使用 CMake 与 Gradle 搭配使用来构建原生库。具体来说,我们可以使用 Gradle 将 C C 代码 编译到原生库中,然后将这些代码打包到我们的应用中, Java 代码随后可以通过 Java 原生接口 ( JNI ) 调用 我们原生库中的函数。

使用CMake开发NDK项目需要下载如下一些套件:

  • Android 原生开发工具包 (NDK):这套工具集允许我们 开发 Android 使用 C 和 C 代码,并提供众多平台库,让我们可以管理原生 Activity 和访问物理设备组件,例如传感器和触摸输入。
  • CMake:一款外部构建工具,可与 Gradle 搭配使用来构建原生库。如果你只计划使用 ndk-build,则不需要此组件。
  • LLDB:一种调试程序,Android Studio 使用它来调试原生代码。

我们可以打开Android Studio,依次选择 【Tools】 > 【Android】> 【SDK Manager】> 【SDK Tools】选中LLDB、CMake 和 NDK即可。

启用CMake还需要在 app/build.gradle 中添加如下代码。

代码语言:txt复制
android {
    ···
    defaultConfig {
        ···
        externalNativeBuild {
            cmake {
                cppFlags ""
            }
        }

        ndk {
            abiFilters 'arm64-v8a', 'armeabi-v7a'
        }
    }
    ···
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }
}

然后,在对应目录新建一个 CMakeLists.txt 文件,添加代码。

代码语言:txt复制
# 定义了所需 CMake 的最低版本
cmake_minimum_required(VERSION 3.4.1)

# add_library() 命令用来添加库
# native-lib 对应着生成的库的名字
# SHARED 代表为分享库
# src/main/cpp/native-lib.cpp 则是指明了源文件的路径。
add_library( # Sets the name of the library.
        native-lib

        # Sets the library as a shared library.
        SHARED

        # Provides a relative path to your source file(s).
        src/main/cpp/native-lib.cpp)

# find_library 命令添加到 CMake 构建脚本中以定位 NDK 库,并将其路径存储为一个变量。
# 可以使用此变量在构建脚本的其他部分引用 NDK 库
find_library( # Sets the name of the path variable.
        log-lib

        # Specifies the name of the NDK library that
        # you want CMake to locate.
        log)

# 预构建的 NDK 库已经存在于 Android 平台上,因此,无需再构建或将其打包到 APK 中。
# 由于 NDK 库已经是 CMake 搜索路径的一部分,只需要向 CMake 提供希望使用的库的名称,并将其关联到自己的原生库中

# 要将预构建库关联到自己的原生库
target_link_libraries( # Specifies the target library.
        native-lib

        # Links the target library to the log library
        # included in the NDK.
        ${log-lib})
···

参考:Android NDK开发基础

6,动态加载

6.1 基本概念

动态加载技术在Web中很常见,对于Android项目来说,动态加载的目的是让用户不用重新安装APK就能升级应用的功能,主要的应用场景是插件化和热修复。

首先需要明确的一点,插件化和热修复不是同一个概念,虽然站在技术实现的角度来说,他们都是从系统加载器的角度出发,无论是采用hook方式,亦或是代理方式或者是其他底层实现,都是通过“欺骗”Android 系统的方式来让宿主正常的加载和运行插件(补丁)中的内容;但是二者的出发点是不同的。

插件化,本质上是把需要实现的模块或功能当做一个独立的功能提取出来,减少宿主的规模,当需要使用到相应的功能时再去加载相应的模块。而热修复则往往是从修复bug的角度出发,强调的是在不需要二次安装应用的前提下修复已知的bug。

为了方便说明,我们先理清几个概念:

  • 宿主: 当前运行的APP。
  • 插件: 相对于插件化技术来说,就是要加载运行的apk类文件。
  • 补丁: 相对于热修复技术来说,就是要加载运行的.patch,.dex,*.apk等一系列包含dex修复内容的文件。

下图展示了Android动态化开发框架的整体的架构。

在这里插入图片描述在这里插入图片描述

6.2 插件化

关于插件化技术,最早可以追溯到2012年的 AndroidDynamicLoader ,其原理是动态加载不同的Fragment实现UI替换,不过随着15,16年更好的方案,这个方案渐渐的被淘汰了。再后来有了任玉刚的dynamic-load-apk方案,开始有了插件化的标准方案。而后面的方案大多基于Hook和动态代理两个方向进行。

目前,插件化的开发并没有一个官方的插件化方案,它是国内提出的一种技术实现,利用虚拟机的类的加载机制实现的一种技术手段,往往需要hook一些系统api,而Google从Android9.0开始限制对系统私有api的使用,也就造成了插件化的兼容性问题,现在几个流行的插件化技术框架,都是大厂根据自己的需求,开源出来的,如滴滴的VirtualAPK,360的RePlugin等,大家可以根据需要自行了解技术的实现原理。

6.3 热修复

6.3.1 热修复原理

说到热修复的原理,就不得不提到类的加载机制,和常规的JVM类似,在Android中类的加载也是通过ClassLoader来完成,具体来说就是PathClassLoader 和 DexClassLoader 这两个Android专用的类加载器,这两个类的区别如下。

  • PathClassLoader:只能加载已经安装到Android系统中的apk文件(/data/app目录),是Android默认使用的类加载器。
  • DexClassLoader:可以加载任意目录下的dex/jar/apk/zip文件,也就是我们一开始提到的补丁。

这两个类都是继承自BaseDexClassLoader,BaseDexClassLoader的构造函数如下。

代码语言:txt复制
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

这个构造函数只做了一件事,就是通过传递进来的相关参数,初始化了一个DexPathList对象。DexPathList的构造函数,就是将参数中传递进来的程序文件(就是补丁文件)封装成Element对象,并将这些对象添加到一个Element的数组集合dexElements中去。

前面说过类加载器的作用,就是将一个具体的类(class)加载到内存中,而这些操作是由虚拟机完成的,对于开发者来说,只需要关注如何去找到这个需要加载的类即可,这也是热修复需要干的事情。

在Android中,查找一个名为name的class需要经历如下两步:

  1. 在DexClassLoader的findClass 方法中通过一个DexPathList对象findClass()方法来获取class。
  2. 在DexPathList的findClass 方法中,对之前构造好dexElements数组集合进行遍历,一旦找到类名与name相同的类时,就直接返回这个class,找不到则返回null。

因此,基于上面的理论,我们可以想到一个最简单的热修复方案。假设现在代码中的某一个类出现Bug,那么我们可以在修复Bug之后,将这些个类打包成一个补丁文件,然后通过这个补丁文件封装出一个Element对象,并且将这个Element对象插到原有dexElements数组的最前端。这样,当DexClassLoader去加载类时,由于双亲加载机制的特点,就会优先加载插入的这个Element,而有缺陷的Element则没有机会再被加载。事实上,QQ早期的热修复方案就是这样的。

6.3.2 QQ 空间超级补丁方案

QQ 空间补丁方案就是使用javaassist 插桩的方式解决了CLASS_ISPREVERIFIED的难题。涉及的步骤如下:

  • 在apk安装的时候系统会将dex文件优化成odex文件,在优化的过程中会涉及一个预校验的过程。
  • 如果一个类的static方法,private方法,override方法以及构造函数中引用了其他类,而且这些类都属于同一个dex文件,此时该类就会被打上CLASS_ISPREVERIFIED。
  • 如果在运行时被打上CLASS_ISPREVERIFIED的类引用了其他dex的类,就会报错。
  • 正常的分包方案会保证相关类被打入同一个dex文件。
  • 想要使得patch可以被正常加载,就必须保证类不会被打上CLASS_ISPREVERIFIED标记。而要实现这个目的就必须要在分完包后的class中植入对其他dex文件中类的引用。
6.3.3 Tinker

QQ空间超级补丁方案在遇到补丁文件很大的时候耗时是非常严重的,因为一个大文件夹加载到内存中构建一个Element对象时,插入到数组最前端是需要耗费时间的,而这非常影响应用的启动速度。基于这些问题,微信提出了Tinker 方案。

Tinker的思路是,通过修复好的class.dex 和原有的class.dex比较差生差量包补丁文件patch.dex,在手机上这个patch.dex又会和原有的class.dex 合并生成新的文件fix_class.dex,用这个新的fix_class.dex 整体替换原有的dexPathList的中的内容,进而从根本上修复Bug,下图是演示图。

在这里插入图片描述在这里插入图片描述

相比QQ空间超级补丁方案,Tinker 提供的思路可以说效率更高。对Tinker热修复方案感兴趣的同学可以去看看Tinker 源码分析之DexDiff / DexPatch

6.3.4 HotFix

以上提到的两种方式,虽然策略有所不同,但总的来说都是从上层ClassLoader的角度出发,由于ClassLoader的特点,如果想要新的补丁文件再次生效,无论你是插桩还是提前合并,都需要重新启动应用来加载新的DexPathList,从而实现Bug的修复。

AndFix 提供了一种运行时在Native修改Filed指针的方式,实现方法的替换,达到即时生效无需重启,对应用无性能消耗的目的。不过,由于Android在国内变成了安卓,各大手机厂商定制了自己的ROM,所以很多底层实现的差异,导致AndFix的兼容性并不是很好。

6.3.5 Sophix

Sophix采用的是类似类修复反射注入方式,把补丁so库的路径插入到nativeLibraryDirectories数组的最前面, 这样加载so库的时候就是补丁so库而不是原来的so库。

在修复类代码的缺陷时,Sophix对旧包与补丁包中classes.dex的顺序进行了打破与重组,使得系统可以自然地识别到这个顺序,以实现类覆盖的目的。

在修复资源的缺陷时,Sophix构造了一个package id 为 0x66 的资源包,这个包里只包含改变了的资源项,然后直接在原有AssetManager中addAssetPath这个包即可,无需变更AssetManager对象的引用。

除了这些方案外,热修复方案还有美团的Robust、饿了吗的Amigo等。不过,对于Android的热修复来说,很难有一种十分完美的解决方案。比如,在Android开发中,四大组件使用前需要在AndroidManifest中提前声明,而如果需要使用热修复的方式,无论是提前占坑亦或是动态修改,都会带来很强的侵入性。同时,Android碎片化的问题,对热修复方案的适配也是一大考验。

参考:Android 热修复的简析

深入探索Android热修复技术原理

0 人点赞