车载多 App 同屏渲染(一):应用层插件化

34 阅读7分钟

这是「多 App 同屏渲染」系列的第一步。本文的实现先在应用层落地——普通宿主 App 通过 ClassLoader 加载外部插件 APK,把插件里的界面渲染到自己的 Activity 上,不需要系统签名、不依赖 AOSP。系统层的方案(在 AAOS 上用 SystemUI Plugin / TaskView 实装)正在验证测试中,会在后续几篇里补上。

为什么要做这件事

智能座舱的 Launcher 越来越像一个「聚合入口」:媒体、天气、车控、导航,各业务都想以卡片的形式挂到桌面上。卡片少的时候还能由 Launcher 团队自己写;卡片一多,就得把它们交给各业务 Owner,再以 AAR 的方式集成进来。

但 AAR 是编译期集成,业务和 Launcher 之间的版本、发布仍然强耦合。卡片规模再往上走,Launcher 的编译、联调、维护都会变重。于是很自然会问一句:

能不能让卡片不再参与 Launcher 的编译,而是像插件一样,由独立 APK 提供,运行时被 Launcher 动态发现、加载、显示?

这就是这篇要落地的东西。把 Launcher 从「所有业务代码的承载者」变成「插件的宿主和容器」:宿主只定义接口和展示位,具体卡片由各插件 APK 分别实现。

这一版用一个仿车机 Launcher 的壳子当宿主,加载一个独立的音乐播放器插件 APK,把播放器界面显示在 Launcher 的一块区域里。

三个工程

Github/
├── plugin-api/                  共享接口模块(宿主和插件之间唯一的契约)
├── CarLauncherShell/            宿主:车机 Launcher 壳,负责发现、加载、展示插件
└── AppProviderHost/
    └── PluginAudioPlayerApp/    插件:一个 Compose 写的音乐播放器
  • 宿主对 plugin-apiimplementation(打包接口);
  • 插件对 plugin-apicompileOnly(只编译期用,不打包);
  • 两个工程各自的 settings.gradle.kts 用相对路径 include 同一个 plugin-api 目录。

一条主线:宿主依赖接口,不依赖实现

整套机制的核心就一句话——宿主只认接口。它不知道「音乐播放器」是怎么画的,只知道有这么个约定:

// plugin-api:用 Java 写,模块只需 com.android.library,不牵扯 Kotlin/Compose
public interface PluginInterface {
    View createView(Context context);
}

插件实现它,内部爱用什么写都行。这里插件用的是 Compose,所以包一层 ComposeView:

class PluginImpl : PluginInterface {
    override fun createView(context: Context): View =
        ComposeView(context).apply {
            setContent { MusicPlayerWidget(Modifier.fillMaxSize()) }
        }
}

宿主拿到实例后,直接按接口调用,不碰任何插件的具体类:

val plugin = clazz.getDeclaredConstructor().newInstance() as PluginInterface
val view = plugin.createView(hostContext)
container.addView(view)

反射只用在「把插件类实例化」这一下;真正取 View 靠的是接口多态,不是一路 getMethod().invoke()

难点一:接口「各有一份」时,强转会崩

一个直觉上的做法是:宿主和插件各写一份一模一样的 PluginInterface,反射出实例后强转。这会直接 ClassCastException

原因是 JVM/ART 里一个类的身份 = 全限定名 + 定义它的 ClassLoader。宿主那份接口和插件那份接口,名字一样,但由不同的 ClassLoader 定义,是两个不同的 Class。强转比的是 Class 身份,不是名字。

要能强转,必须让接口在运行时解析到同一个 Class。做法是接口下沉到 plugin-api,插件 compileOnly 不打包,再让插件的 ClassLoader 在加载接口时委托给宿主。

难点二:用 ClassLoaderFilter 做「白名单委托 + 其余隔离」

给插件建一个 PathClassLoader,它的 parent 不直接暴露宿主全量 ClassLoader,而是包一层过滤器:白名单里的包委托给宿主加载,其余的走 boot、插件自有类由 PathClassLoader 自己从插件 APK 兜底。

class ClassLoaderFilter(
    private val base: ClassLoader,          // 宿主 ClassLoader
    private val whitelist: List<String>,
) : ClassLoader(bootClassLoader()) {        // parent 必须是 boot,不能是 null(见下文)

    override fun loadClass(name: String, resolve: Boolean): Class<*> {
        if (whitelist.any { name.startsWith(it) }) {
            return base.loadClass(name)     // 白名单 → 宿主那份(Class 唯一)
        }
        return super.loadClass(name, resolve)
    }
}

private fun bootClassLoader(): ClassLoader? {
    var cl: ClassLoader? = ClassLoader.getSystemClassLoader()
    while (cl?.parent != null) cl = cl.parent
    return cl
}

白名单里放三类:

  • com.example.pluginapi. —— 接口,解决强转;
  • androidx. / kotlin. / kotlinx. —— 让插件用宿主那份 Compose / 协程 / stdlib,共享同一套 runtime;
  • 框架类 java.* / android.* 走 boot,天然共享。

插件自己的 com.example.myapplication.* 不在白名单,由子 PathClassLoader 从插件 APK 加载,和宿主隔离。这不是安全沙箱(插件代码仍在宿主进程里跑),只是 ClassLoader 层面的可见性隔离。

发现插件:借 PackageManager 当注册表

插件在自己的 Manifest 里声明一个 <service>,它永远不会被启动,只是借 PackageManager 的 Intent 查询机制当注册表:

<service android:name="com.example.myapplication.PluginImpl" android:exported="true">
    <intent-filter>
        <action android:name="com.example.plugin.action.WIDGET" />
    </intent-filter>
</service>

宿主 queryIntentServices(ACTION) 就能发现所有声明了该 action 的插件,serviceInfo.name 就是要反射实例化的入口类。Android 11+ 记得在宿主 Manifest 里用 <queries> 声明可见性,否则查询结果为空。

把上面几步串起来,宿主侧的加载器大致是:

val resolve = pm.queryIntentServices(Intent(PLUGIN_ACTION), 0).firstOrNull()
val pkg = resolve.serviceInfo.packageName
val entry = resolve.serviceInfo.name

val appInfo = pm.getApplicationInfo(pkg, 0)
// base + 所有 split 都放进 dexPath
val dexPath = buildList {
    add(appInfo.sourceDir)
    appInfo.splitSourceDirs?.let { addAll(it.asList()) }
}.joinToString(File.pathSeparator)

val filter = ClassLoaderFilter(hostContext.classLoader, WHITELIST)
val loader = PathClassLoader(dexPath, appInfo.nativeLibraryDir, filter)

val plugin = Class.forName(entry, false, loader)
    .getDeclaredConstructor().newInstance() as PluginInterface
container.addView(plugin.createView(hostContext))

插件是 Compose 时,版本必须一致

插件界面是 Compose,宿主界面也是 Compose。如果两边各用各的 Compose runtime,两套 ComposeView 在同一进程里会打架。所以走白名单让插件复用宿主那份 androidx.compose.*——前提是两边 Compose / Kotlin 版本对齐,否则会 NoSuchMethodError

实际做法就是把宿主的 Kotlin、Compose BOM 对齐到插件同一版本。改任一边版本,另一边要同步。

尺寸:宿主拥有盒子,插件负责填满

一开始插件里把界面写死成 width(300).height(500),结果和宿主区域对不上。正确的分工是:宿主通过容器大小 / LayoutParams 决定边界,插件用 fillMaxSize + 内部 weight 自适应填满,不写死 dp。

// 宿主:让插件 View 填满区域
container.addView(view, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
// 插件:填满宿主给的容器
setContent { MusicPlayerWidget(Modifier.fillMaxSize()) }

同进程共用宿主的 display / density,dp↔px 换算天然一致,不用自己算。只有插件要按像素画东西、或者跨进程 / 跨屏嵌入时,才需要把宽高(必要时连 Configuration)显式写进接口契约传进去。

几个真踩到的坑

强转 ClassCastException。 就是难点一。接口没下沉、两边各打一份,必崩。解决:plugin-api + 插件 compileOnly + 白名单委托。

NoClassDefFoundError: Failed resolution of: Ljava/lang/Object; filter 的 parent 一开始写成了 ClassLoader(null)。OpenJDK 里 null parent 会回退到 bootstrap 加载 java.*,但 ART 的 ClassLoader.loadClass 没有这个回退,parent 为 null 时直接 findClass 失败,连 java.lang.Object 都解析不了,任何插件类都定义不出来。解决:filter 的 parent 传 boot classloader(从 getSystemClassLoader() 往上走到顶),不能是 null。

ClassNotFoundException: Didn't find class PluginImpl on path base.apk 明明发现阶段类名都对,加载却说找不到。原因是用 Android Studio 的 Apply Changes / 增量部署更新的插件,新类进了插件进程私有的 code_cache/.overlay/ 覆盖 dex,磁盘上的 base.apk 还是旧的;而宿主用 PathClassLoader(base.apk) 只读 base.apk,读不到 overlay。解决:插件走一次干净全量安装(adb uninstall + assembleDebug + adb install --no-incremental),别用 Apply Changes。

虚线边框显示不全。 纯绘制问题:Stroke 描边以路径边缘为中心,外层 clip(RoundedCornerShape) 把描边靠外的一半裁掉了。解决:把圆角矩形内缩半个线宽再画。

应用层方案的边界

这一版能跑,但要清楚它的定位:插件代码最终运行在宿主进程里。所以它适合受控生态——自家业务插件、同签名校验、版本自己管;不适合加载不可信的第三方 APK。

真正要做到「任意未修改的 App 也能同屏」「插件崩了不拖垮宿主」「进程级隔离」,还得靠系统层能力:SystemUI Plugin Framework(平台签名 + 特权进程动态加载)、TaskView / CarTaskView(系统级 Task 嵌入)、Scalable UI(声明式窗口编排)。这些需要 AOSP / AAOS 环境和平台签名,目前正在 AAOS 上实装、测试,后续单独写。

所以把这篇当作坐标系的原点:先用应用层插件化把「宿主动态加载并显示外部 APK 界面」这条链路跑通、把 ClassLoader / 接口 / Compose 共享这些基础问题搞明白,再往系统层走会顺很多。

效果

仿车机 Launcher 的宿主,横排三块区域分别对应三种同屏渲染方案:APP 插件(本文,练习 1)、SurfaceControl(练习 2)、RemoteCompose(练习 3)。第一块「APP 插件」已经把外部音乐播放器 APK 的界面加载了进来,另外两块留了接入口,后续补上;底部是常用 App 的 dock。 在这里插入图片描述

参考

  • 学习思路借鉴自这篇文章:《从 AAR 到 Plugin:Launcher 卡片插件化解耦实践》 juejin.cn/post/767855…