【翻译】KMP 的另一条路:共享渲染器

0 阅读7分钟

KMP 的另一条路:共享渲染器

原文链接:proandroiddev.com/another-way…

原文作者:Gabor Berenyi

本系列第三篇。此前:在 Compose Multiplatform 中构建 3D 游戏在 React Native + Kotlin Multiplatform 中构建 3D 游戏

Android

Android

iOS

iOS

在前两篇文章里,我把 3D 国际象棋做了两遍——一次用 Compose Multiplatform 外壳,一次用 React Native。两种情况下,游戏逻辑都在共享 Kotlin 里,由平台专用的 3D 渲染器展示状态。

那套逻辑充当了严格的单一事实来源。核心引擎从 Compose Multiplatform 仓库作为独立 Maven 工件(io.github.ber4444:chess-core)发布,再经一层薄包装被 React Native 应用消费。两个项目之间没有重复的 Kotlin。连 React Native 的 TypeScript 绑定也是直接从 Kotlin 的 @JsExport 声明自动生成的,保证类型永远不会漂移。

本文讲的是一个把那套架构颠倒过来的项目——以及为什么这种颠倒是正确的。

应用:自定义星系渲染器

这款应用把银河系渲染成真实的 3D 环境——数以万计的单颗恒星,你可以飞进去,每颗星的明暗在实时计算。静态背景图在这里行不通;重点恰恰是一个可导航的活场景。怎么_画的细节对本文无关紧要。要紧的是那段代码_住在哪里

翻转架构

在象棋应用里,有价值的代码是规则(着法合法性、将军检测),渲染则是商品件。这里重心反过来了:首要资产_就是_渲染器。它是定制的,正是应用长成这副样子的全部理由,也正是那种你绝不想写两遍的代码。

由于图形调用是平台专用绑定,而不是共享 API,要保住这份代码就得回答一个新问题:

能否把图形 API 隔在一道接缝后面,让渲染器本身住进 commonMain

可以。主架构边界落在完全不同的一层,如下图所示:

架构图

在以往项目里,Kotlin 握着规则,引擎宿主坐在它下面、按平台散落。现在栈被重组为:

  • 顶层(原生应用): 只有一薄层表面胶水与手势输入(iOS 上是 Swift,Android 上是 Kotlin)。渲染器模块从不看见 ViewViewController
  • 中层(共享 Kotlin 核心): 这里放着真正的渲染器、着色器资源、相机运动数学,以及视觉叠加层。
  • 底层(接缝与转译): 一道 GL 门面(façade)把共享核心与平台驱动隔开,把调用下放到平台图形 API,或在 iOS 上经转译层转发。

还是同一种语言,但重活发生在完全不同的位置。

共享核心的契约

凡是会画的东西都住在 commonMain:渲染器、相机数学、数据解析器,以及一份把常量单一来源化的约定文件。androidMainiosMain 只贡献严格平台专用的 API 委托与图像解码器。

契约刻意做得很小,以免共享代码长出平台专用触手。它的形状编码了两条架构规则:

  • 生命周期是契约的一部分。 渲染器把 init()(CPU 状态:解析数据、加载资源——只跑一次)与 onGlContextCreated()(GPU 状态:程序、缓冲——在_每一次_上下文创建时跑)分开。若渲染器契约没有显式建模上下文丢失,操作系统就会替你建模——在它选中的某个时刻。
  • 编排住在共享代码里。 平台手势增量与时间戳被转发进 commonMain,确保跨平台的相机运动与动量一致。因为相机逻辑是纯 Kotlin、无图形依赖,它在 JVM 上的 commonTest 下就能跑——不需要模拟器、不需要真机、不需要 GPU。

模块边界规则很严。若一行代码需要平台,它就不共享。接缝严格只传哑数据,从不传活对象。

你不会白捡的那道接缝

这里是从 Kotlin 共享图形 API 需要付出代价的唯一一处。

图形调用是平台专用的。在 Android 上,你调用 JVM 绑定。在 iOS 上,Kotlin/Native 的原始 C 互操作(C-interop)桩会产出 Swift 无法干净消费的复杂类型。解法是一层薄 C 垫片(shim)——34 个接受普通 C 类型的包装函数,经 cinterop 编译。Kotlin 调用垫片;垫片再调用平台 API。这是对共享核心完全不可见的一层架构。

于是共享渲染器通过一道薄门面交谈——commonMain 里一个 interface,枚举本渲染器实际会打的调用,外加两个 class 实现。androidMain 把每一个实现成对 Android API 的一行转发;iosMain 则各自实现在 C 垫片之上。

// commonMain — 渲染器被允许触碰的全部表面
interface Gl {
    fun createProgram(vertexSrc: String, fragmentSrc: String): Int
    fun createBuffer(): Int
    fun createTexture(): Int
    fun bufferData(target: Int, data: FloatArray, usage: Int)
    fun drawArrays(mode: Int, first: Int, count: Int)
    // …约再多 29 个
}

句柄就是普通的 Int。让这套东西成立的纪律是:每一次上传都以 FloatArrayByteArray 越过边界,从不传平台缓冲。

门面的全部工作,是让跨边界数据在两侧意味着同一件事。例如,平台专用图像解码器对数据结构的处理方式不同。与其让平台怪癖渗进共享渲染器,共享代码带着归一化标志来处理差异,从而维持严格边界。

大约三十来个方法就是全部税负。门面之上的一切只写一遍,用 Kotlin,且永远不知道自己在哪个平台上。

转译这些调用

门面决定_Kotlin 如何发出调用_。在它下面,驱动级转译层(ANGLE)决定_这些调用实际跑在什么上_——而两个平台的差异,恰好验证了这套设计:

  • 在 Android 上,操作系统越来越多地把这些调用经系统 ANGLE 路由到 Vulkan。应用里什么都不用改;现代后端白捡。
  • 在 iOS 上,从 Apple 已弃用的框架升级到 Metal(经 MetalANGLE)是一次构建配置变更:一个 Gradle 属性重指向 cinterop,Swift 宿主换掉自己的视图层。Kotlin 源码完全不动。

这就是 C 垫片的回报:这一层间接让图形提供者成为_链接期_选择,而不是改代码。共享核心永远不知道自己跑在哪个后端上。

收获

两边汇合策略都交付过之后,下次项目我会这样框定决策:

  • 跨多平台的商品级 3D 场景 → 用成熟引擎(如 Filament)。引擎扛起重活;你按平台宿主它。
  • 你想用一种语言拥有的自定义渲染器 → Kotlin Multiplatform 加一道门面。渲染器、它的测试,以及它之上的一切,与应用其余部分同住一个 Kotlin 代码库。转译层在下面处理 API 差异。那 34 个调用的接口,就是「不逐字共享调用」的全部代价。

这个项目说明:有时可共享的东西并不是渲染器之上的规则——而是胶水之下的渲染器。边界是围着真正要紧的东西画的;一旦站住,就会持续兑现:新的渲染器特性在 commonMain 里写一次,就会出现在两个平台上,无需按平台再做一遍。

把有价值的代码放在一处,让接缝保持哑巴,并让每个平台只贡献它独自能提供的东西:一块渲染表面与用户输入。

仓库:github.com/ber4444/mil…