Shadow插件化实践

背景

我们集成阿里人脸识别sdk后,包体大了5M(so已动态下发),同时运行时code部分内存也多了将近20M。考虑到人脸识别并不是一个主要业务路径下的功能,于是想将其做成插件按需使用。经过调研shadow无hook android 私有系统api并且依旧在维护,是契合我们当前需求的插件化框架。

经过考量我们并没有将整个工程按照业务划分为不同插件,而是单独将人脸识别sdk做成插件,宿主能加载并执行。技术方案上采用Shadow给出的no-dynamic方式接入。这种方式能让宿主直接访问到插件Classloader,Resources,非常适合插件宿主不是非常独立的场景。

文中罗列了我们集成遇到的一些问题,以方便后来者。

1.集成问题

1.1 宿主如何使用插件能力

在Shadow中负责插件加载的是ShadowPluginLoader,其内部调用LoadPluginBloc.loadPlugin() ,其负责构造Resources、Application、ClassLoader等并将结果存在ShadowPluginLoadermPluginPartsMap字段中。外部可以以下api访问到插件ClassLoader,Resources等信息。

宿主使用插件能力一般体现为调用插件中某个类的方法,所以比较直观的思路是:获取插件ClassLoader然后反射调用(静态或者实例)方法。伪代码如下:

java 复制代码
ShadowPluginLoader loader = getPluginLoader();
ClassLoader cl = loader.getPluginParts("插件名称").getClassLoader();
Class<?> clazz = cl.loadClass("com.xxx.xx");
Method m = clazz.getMethod("xxxx");
m.invoke(null);

当然上述代码只是只能作为一个演示,实际上还有两个问题需要考虑:

  1. 代码混淆问题。
  2. 调用业务如何无感使用插件。像插件名业务调用实际上是不需要知道的。

对于混淆问题,其实是调用方依赖了插件的具体细节。我们只需要遵循依赖倒置原则,插件和宿主之间的通信只依赖接口,然后将接口keep住即可。调用方通过ServiceLoader获取接口的具体实现。

java 复制代码
ShadowPluginLoader loader = getPluginLoader();
ClassLoader cl = loader.getPluginParts("插件名称").getClassLoader();
xxxIntreface func = ServiceLoader.load(xxxIntreface.class,cl);
func.xxx()

上述代码中我们依旧通过插件的partName获取对应插件的ClassLoader,其实我们使用ServiceLoader来做服务发现,需要在插件工程的META-INF/services中给出接口对应的实现。

所以插件实际上是知道自己对外提供了哪些服务。我们只需要把这份关系保存在config.json中,Shadow解析插件时维护一份接口服务与对应插件的映射关系即可。那么调用方实际只需要给出调用接口即可。

我们将使用插件能力封装下

kotlin 复制代码
object PluginLoadManager {
    private var exportLoadMap = ConcurrentHashMap<String, String>()

    fun <T> getInstanceFromPluginNotCheck(clazz: Class<T>): T? {
        val partName = exportInterface[clazz.name]
        if (partName.isNullOrEmpty()) {
            return null
        }
        val allCl = pluginManager.allPluginClassLoader()
        if (allCl.isEmpty()) {
            return null
        }
        val cl = allCl[partName] ?: return null
        val loader = ServiceLoader.load(clazz, cl)
        if (loader.isEmpty()) {
            return null
        }
        val result = loader.first()
        if (result != null) {
            exportLoadMap[(result!!)::class.java.name] = partName
        }
        return result
    }
}

那么调用方只需要给出相关接口就能获取对应实例

kotlin 复制代码
PluginCenter.getInstanceFromPluginNotCheck(IFaceVerify::class.java)?.verify()

1.2 Context问题

由于我们只是人脸识别sdk做了插件化改造,外部初始化sdk还是宿主的Context,而不是Shadow的Context。如果sdk内部存在利用Context跳转或者获取Resources之类的,需要构造成ShadowContext传递给插件。

我们以ShadowContext.startActivity为例,ShadowContext在内部跳转插件Activity需要把Intent包装成跳转"壳子"Activity,以达到跳转未在AndroidManifest注册Activity效果。

kotlin 复制代码
// ComponentManager.kt
override fun startActivity(
    shadowContext: ShadowContext,
    pluginIntent: Intent,
    option: Bundle?
): Boolean {
    return if (pluginIntent.isPluginComponent()) {
        shadowContext.superStartActivity(pluginIntent.toActivityContainerIntent(), option)
        true
    } else {
        false
    }
}

当插件初始化时如果传入宿主Context,插件内部的Activity肯定是没有在宿主Manifest注册的,这样的跳转就不会成功。

java 复制代码
public void verify(String str, boolean z, HashMap<String, String> hashMap, ZIMCallback zIMCallback) {
    this.ctx.startActivity(intent);
}

这个时候需要用ShadowContext传递给插件。在我们项目中,我们选择了在ShadowApplication传递给插件。

kotlin 复制代码
val ctx = PluginCenter.getPluginContext(requireContext(), faceVerify)
faceVerify.verify(ctx, certifyId, true, object : IVerifyCallback {
    override fun notifyResult(resp: FaceResponse): Boolean {
        return true
    }
})

同时需要重写ShadowApplication的superStartActivity方法,将实际启动Activity转调宿主。

这样的代码看起来确实像是临时的修补,考虑到在宿主中使用插件并在插件中有跳转行为这一种情况才需要借助宿主的能力。其他情况例如插件中跳转其他插件Activity Shadow框架都做好了正确的转调给对应的宿主,即PluginContainerActivity

java 复制代码
// ShadowActivity

@Override
public void startActivityForResult(Intent intent, int requestCode, Bundle options) {
    final Intent pluginIntent = new Intent(intent);
    pluginIntent.setExtrasClassLoader(mPluginClassLoader);
    ComponentName callingActivity = new ComponentName(getPackageName(), getClass().getName());
    final boolean success = mPluginComponentLauncher.startActivityForResult(hostActivityDelegator, pluginIntent, requestCode, options, callingActivity);
    if (!success) {
        hostActivityDelegator.startActivityForResult(intent, requestCode, options);
    }
}

当然也存在一种情况插件Sdk内部也使用ShadowApplication 启动插件Activity。我们只需要注册内部注册下registerActivityLifecycleCallbacks 并在onCreate 和onDestory回调中调用addTargetContext更新ShadowApplication的宿主Context即可。

1.3 资源问题

这个问题可以主要分解成两个场景:

  1. 在插件环境如何使用宿主资源。
  2. 在非插件环境如何混合使用插件宿主资源。

针对场景1,有多种方式可以达成目的。最简单的可以使用Resources.getIdentifier()

scss 复制代码
private String getHostStr() {
    int id = getResources().getIdentifier("test_host_res_str", "string", getContext().getPackageName());
    Log.i("TestOutFragment", "resourceid: " + id);
    return getResources().getString(id);
}

这种方式能work的原因是因为在构造插件Resources时会添加宿主的资源路径,所以插件可以无障碍访问宿主资源。当然如果直接通过资源id访问可能会随着宿主添加资源导致id失效。

kotlin 复制代码
private fun fillApplicationInfoForNewerApi(
    applicationInfo: ApplicationInfo,
    hostApplicationInfo: ApplicationInfo,
    pluginApkPath: String
) {
    /**
     * 这里虽然sourceDir和sharedLibraryFiles中指定的apk都会进入Resources对象,
     * 但是只有资源id分区大于0x7f时才能在加载之后保持住资源id分区。
     * 如果把宿主的apk路径放到sharedLibraryFiles中,我们假设宿主资源id分区是0x7f,
     * 则加载后会变为一个随机的分区,如0x30。因此放入sharedLibraryFiles中的apk的
     * 资源id分区都需要改为0x80或更大的值。
     *
     * 考虑到现网可能已经有旧方案运行的宿主和插件,而宿主不易更新。
     * 因此新方案假设宿主保持0x7f固定不能修改,但是插件可以重新编译新版本修改资源id分区。
     * 因此把插件apk路径放到sharedLibraryFiles中。
     *
     * 复制宿主的sharedLibraryFiles,主要是为了获取前面WebView初始化时,
     * 系统使用私有API注入的webview.apk
     */
    applicationInfo.publicSourceDir = hostApplicationInfo.publicSourceDir
    applicationInfo.sourceDir = hostApplicationInfo.sourceDir

    // hostSharedLibraryFiles中可能有webview通过私有api注入的webview.apk
    val hostSharedLibraryFiles = hostApplicationInfo.sharedLibraryFiles
    val otherApksAddToResources =
        if (hostSharedLibraryFiles == null)
            arrayOf(pluginApkPath)
        else
            arrayOf(
                *hostSharedLibraryFiles,
                pluginApkPath
            )

    applicationInfo.sharedLibraryFiles = otherApksAddToResources
}

当然我们也可以简化直接使用Resources.getIdentifier()获取资源的方式,这方面参考或者直接使用37手游开发的SqInject [1]。

如果有很多人力的话可以复刻一个字节提出的免资源固定方案[2**]。**

针对场景2,在我们业务中主要是想使用插件中的Fragment。这个问题在Shadow issue也有过讨论issue 319。但是讨论的方案过于繁琐。

从Fragment构成来说,它本质上还是让一个View有个生命周期的辅助工具,其自身是没有window和Context。所以Fragment在Inflate view用的Context还是Activity的。所以如果宿主想要嵌入插件Fragment,一定要提供ShadowContext一样的能力。具体的来说就是

  1. 重写宿主Activity的getResources方法,返回对应插件构造的Resources。这样插件的View就能获取对应的资源并创建。
  1. 宿主实现ShadowContext.PluginComponentLauncher接口,使得插件内部startActivity等函数能按照预期跳转插件或者宿主目标。同时也需要修改ShadowFragmentSupportfragmentStartActivity如下
java 复制代码
public static void fragmentStartActivity(Fragment fragment, Intent intent, Bundle options) {
    Intent containerActivityIntent;
    if (fragment.getActivity() instanceof ShadowContext.PluginComponentLauncher) {
        ShadowContext.PluginComponentLauncher launcher = (ShadowContext.PluginComponentLauncher) fragment.getActivity();
        containerActivityIntent = launcher.convertPluginActivityIntent(intent);
    } else {
        ShadowContext shadowContext = fragmentGetActivity(fragment);
        containerActivityIntent = shadowContext.mPluginComponentLauncher.convertPluginActivityIntent(intent);
    }

    if (options == null) {
        fragment.startActivity(containerActivityIntent);
    } else {
        fragment.startActivity(containerActivityIntent, options);
    }
}

2.插件使用宿主能力

Shadow中提供了白名单机制,配置在白名单的类可以使用宿主加载的class。在大多数场景下已经能解决问题了。

但是如果项目内有全局插桩方法转调的话,需要考虑Shadow字节码编辑的影响。

2.1 反射+白名单机制

具体来说Shadow会将方法签名中的Activity、Application替换成ShadowActivity、ShadowApplication。

在我们项目中全局方法调用替换一般应用在隐私合规,权限申请等地方。比如我们要将所有的activityA.requestPermissions(xxx,xxx) 替换成ActivityPatch.requestPermissions(activityA,xxx,xxx)。那么我们我们的插桩无论是在Shadow字节码编辑之前还是之后都是有问题的。

如果在Shadow编辑字节码之前,则最终方法签名会编辑成ShadowActivity,并且传递的对象是ShadowActivity类型的,方法调用会报类型不匹配。

如果在之后那么按照插桩提供的方法签名,根本没有匹配的。

这个类型不匹配问题,我们可以加中间层解决。中间层负责接收Shadow类型的变量,然后将其转化成原始类型再反射调用原始插桩方法即可。同时将要反射调用的类型加入白名单。简化代码如下

java 复制代码
private static void _invokeHostVoid(String clazzName, String methodName, Class[] paramsType,
    Object... params) {
  ClassLoader cl = AlifacePluginInjectBridge.class.getClassLoader();
  try {
    Class<?> clazz = cl.loadClass(clazzName);
    Method method = clazz.getMethod(methodName, paramsType);
    method.invoke(null, params);
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
}

private static Object wrap(Object obj) {
  if (obj instanceof ShadowActivity) {
    return ((ShadowActivity) obj).getHostActivity();
  }
  return obj;
}

public static void _ActivityPatch_requestPermissions(Activity var0, String[] var1, int var2)
    throws ClassNotFoundException {
  _invokeHostVoid (
      "com.uchia.utils.ActivityPatch",
      "requestPermissions",
      new Class[]{Class.forName("android.app.Activity"), Class.forName("[Ljava.lang.String;"), int.class},
      wrap(var0), wrap(var1), wrap(var2)
      );
}

上述代码只是演示下如何解决类型编辑问题。实际操作中是自动生成桥接代码,可以让javac这个task依赖我们生成代码的task。

3.字节码相关问题

在我们全局asm修改字节码后,Shadow编辑字节码时会报inconsistent stack height Index 2 out of bounds for length 1。这个问题详细描述可以参见issue 1203

经过一番排查后发现是我们asm编辑字节码后,相比原始字节码,将方法的stack size改小了。javaassit认为asm编辑的字节码产物是非法的。

我这边没有采用issue1203中的做法,而是采用issue 1196作者给出的办法,在shadow编辑字节码之前手动修正字节码中的stack size。

kotlin 复制代码
private fun tryFixStackSize(clazz: CtClass, fixList: List<StackSizeCompactIssue>) {
    val matchItem = fixList.firstOrNull { it.className == clazz.name } ?: return
    val method = try {
        clazz.getMethod(matchItem.methodName, matchItem.desc)
    } catch (e: Exception) {
        null
    } ?: return

    if (method.methodInfo.codeAttribute.maxStack < matchItem.expectSize) {
        method.methodInfo.codeAttribute.maxStack = matchItem.expectSize
    }
}

参考

  1. Android APT 实现控件注入框架SqInject
  2. Android 插件化中资源错乱的解决方案
相关推荐
技术小甜甜1 分钟前
【Godot】如何导出 Release 版本的安卓项目
android·游戏引擎·godot
火柴就是我16 分钟前
Dart 原始字符串(Raw Strings)详解文档
android
玲小珑27 分钟前
Auto.js 入门指南(五)实战项目——自动脚本
android·前端
玲小珑28 分钟前
Auto.js 入门指南(四)Auto.js 基础概念
android·前端
没有了遇见2 小时前
DrawerLayout 滑动冲突
android
玲小珑2 小时前
Auto.js 入门指南(六)多线程与异步操作
android·前端
用户2018792831674 小时前
通俗易懂理解Java注解
android
用户2018792831674 小时前
通俗易懂理解泛型
android
linversion4 小时前
如何手动上传Android混淆映射文件Mapping.txt到Firebase
android
慕晨4 小时前
RecyclerView + SnapHelper 滚动差异问题
android