这是「多 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-api用implementation(打包接口); - 插件对
plugin-api用compileOnly(只编译期用,不打包); - 两个工程各自的
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…