Appearance
多屏 Surface 渲染系统
一套 Unity 引擎要把画面同时送到中控大屏、仪表、HUD、WHUD、内部路屏上,Android 的 SurfaceView 和 Unity 的 Display 各有各的就绪时序。这套系统的核心是:一张 10 类 Surface 到物理 Display 的映射表、一个双信号汇合的绑定状态机解决时序竞争、一条 struct 零拷贝的触摸链路,外加一个会"看图自杀"的首帧健康检查器。
Surface 类型10 类映射物理屏
黑屏自愈2 分钟 / 日限 3 次
触摸提频ACTION_DOWN 保 60fps
背景
车里的屏不止一块:中控大屏(SrMainSurface)、仪表盘(SrClusterSurface)、HUD 抬头显示、WHUD 广角抬头、内部路 IM 屏、AutoPilot R 档、CarScene 车景……CameraSurfaceType 枚举一共定义了 10 类 Surface,每类对应一个物理 DisplayId(SrMain→0、SrCluster→1、Hud→2、WHud→4、SrInternalMap→5、CarScene→6、AutoPilot→7,其余为占位或未启用)。
职责边界一句话说清:Surface 决定画面输出到哪块屏,Camera 决定输出什么画面。画面流向是 Camera 渲染 → Surface 承载 → 物理屏幕显示。系统横跨三个技术栈:guideengine(Android/Kotlin)管 SurfaceView 生命周期和窗口,C++ JNI 桥接层管数据转换,Napa5(Unity/C#)管相机和渲染。
问题
| 问题 | 表现 | 根因 |
|---|---|---|
| 就绪时序竞争 | 黑屏、绑定丢失,偶发难复现 | Android Surface 就绪和 Unity 引擎就绪是两个异步事件,谁先谁后不确定 |
| Display 延迟就绪 | Surface 消息到了,Unity 还激活不了渲染 | Android 激活物理 Display 有延迟,Display.displays[] 尚未填充 |
| 触摸跨三层 | 触摸事件要从 Android MotionEvent 走到 Unity 手势识别 | 跨 JNI 的序列化开销和格式不一致 |
| 黑屏无感知 | 引擎活着但画面黑着,用户只能重启车机 | 没有任何组件在"看"渲染结果 |
| 触摸瞬间卡顿 | 低功耗降帧状态下手指按下,前一两帧必卡 | 渲染线程调度优先级没跟上交互节奏 |
方案
绑定状态机:双信号汇合
Android 的 SurfaceView 生命周期是 surfaceCreated → surfaceChanged → surfaceDestroyed,但 Unity 引擎有自己的就绪时间,两个事件互相不知道对方。方案是一个汇合机制:Surface 可用 + Engine 就绪,两者都到齐才执行绑定。
mermaid
stateDiagram-v2
[*] --> UnBounded
UnBounded --> Binding : "surface != null<br>且宽高就绪<br>且引擎就绪"
Binding --> Bounded : "bindSurfaceToRender<br>startRender()"
Bounded --> UnBounding : "surfaceDestroyed"
UnBounding --> UnBounded : "解绑完成"
Binding --> UnBounded : "条件失效回退"时序竞争的具体化解法:
mermaid
sequenceDiagram
participant AS as "Android SurfaceView"
participant SM as "SurfaceManager"
participant BR as "BaseSurfaceRender<br>绑定状态机"
participant UN as "Unity 引擎"
AS->>SM: surfaceChanged(宽高就绪)
SM->>BR: bindSurface()
BR->>BR: 引擎未就绪<br>缓存 surfaceHolder 挂起
UN-->>BR: onSurfaceEngineReady()
BR->>BR: compareAndSet(false→true)
BR->>BR: 发现缓存 holder<br>四条件全满足
BR->>UN: unityService.startRender()
UN-->>AS: 首帧信号 + 纹理指针两个细节值得说:
- surfaceChanged 里先调 onSurfaceCreated 再调 onSurfaceChanged。不是语义矛盾——Android 的 surfaceCreated 回调时 Surface 的宽高尚未确定,只有 surfaceChanged 才携带最终尺寸。把"创建"逻辑推迟到 surfaceChanged 里执行,才能同时拿到有效的 Surface 对象和正确的宽高。
- Unity 侧 CameraSurfaceNode 收到 XSurfaceMsg 后也不立即激活,而是用 0.2s 间隔轮询
HasDisplay(displayId),等Display.displays[]数组就绪后才调 realCreateForExternSurface() 设置相机激活渲染,随后注销 Timer。SrMainSurface 额外调forceCameraRender(10, true)强制渲染 10 秒,防首帧黑屏。
触摸链路:struct 零拷贝
mermaid
flowchart LR
A["MotionEvent<br>Android 系统"] --> B["XPEDriveSurfaceView<br>onTouchEvent"]
B --> C["SurfaceManager<br>dispatchTouchEvent"]
C --> D["JNI 边界<br>NativeTouchStruct 零拷贝"]
D --> E["NapaInput<br>脏手指过滤"]
E --> F["SurfaceTouchRouter<br>区域命中路由"]
F --> G["SurfaceInputContext<br>队列化"]
G --> H["GestureRecognizeTree<br>手势识别"]关键实现
JNI 边界零拷贝。 C++ 层把 Android MotionEvent 反序列化为 NativeTouchStruct(struct 化、零拷贝),处理大小端转换,通过 Unity Native Plugin ABI 投递到 Unity 主线程。触摸这种 120Hz 级的高频事件,任何一次堆分配都会在 GC 上还回来。
脏手指过滤。 NapaInput 的 ShouldFilterDirtyFinger():手指持续 ACTION_STAY 且坐标不变超过 5 秒(DirtyFingerThreshold = 5.0f)就标记为脏手指,防止屏幕脏点/水渍造成的"幽灵触摸"干扰手势。两个豁免设计很务实:FingerId 0/1 不过滤,因为 Android 重新分配 fingerId 大概率从 0、1 开始,下次正常触摸会自动修复;恢复机制有两层——状态或坐标变化立即恢复,标脏超 5 秒后在 GenerateAndRecognizeTouches() 里直接从 TouchDic 移除并清理 capture。
前台互斥契约。 SurfaceVisibleRegionStore 约定 SrMainSurface 和 AutoPilotSurface 不应同时前台(两者共享同一套 HU 相机手势状态机)。CheckForegroundMutex() 是防御性检测:一旦违反,同一根手指会被双路由、手势 delta 应用两次、镜头抖动。SrMain 构造时预填全屏前台——系统启动默认就是大屏在前台,即使上游可见性信号永远不来,主屏触摸也不受影响,这是 fail-safe 设计。
FrameContentMonitor:会看图的健康检查。 首帧渲染后按 4×4 采样点对画面算 FNV-1a 哈希,两种成功判定:帧间 checksum 变化 + 内容正常(动态场景,最快路径);连续 N 帧不是黑/白/空帧(静态场景兜底——静止的 3D 场景 checksum 永远不变,只靠策略一会误判致死)。持续异常超过 2 分钟(fatalTimeoutMs)就截图存证并 killCurrentProcess() 触发进程重启自愈。每天最多 3 次的速率限制是防恶性循环的:引擎启动 → 黑屏 → kill → 重启 → 又黑屏 → 又 kill → 无限循环,没有这个限频设备会被自杀机制搞到完全不可用。
ACTION_DOWN 提频。 SurfaceManager.dispatchTouchEvent() 在 ACTION_DOWN 时调 boostFusionMap(),通过系统级 API 把 Unity 相关的 3 个线程(xpeUnityTid、unityRenderTid、unityMainTid)提升到高优先级调度。时机选 ACTION_DOWN 是因为它比第一帧渲染需求早 1-2 帧,刚好给系统留出提频时间——触摸前地图可能处于低功耗降帧,按下瞬间必须拉满保 60fps。
窗口适配不改渲染。 Unity 始终按全屏分辨率渲染(相机参数不变),Android 侧用 clipBounds 机制只展示窗口区域。分屏/窗口切换时不需要通知 Unity 改分辨率,只需算 Window 与 SurfaceView 的交集更新裁剪区域,SurfaceFlinger 按新区域合成。
首帧信号的差异化。 SrMainSurface 首帧信号携带 targetTexture.GetNativeTexturePtr(),其他 Surface 只发 0——SrMain 走 RenderTexture 模式,Android 侧可直接采样这个 GPU 纹理做后处理(畸变矫正、色彩映射)再输出物理屏;其余 Surface 走直接 Display 渲染,不需要。
指标
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 多屏支持 | 单屏渲染 | 10 类 Surface 映射物理 Display,一引擎多屏 |
| 绑定时序 | 异步竞争,偶发黑屏 | 双信号汇合状态机 + 0.2s 轮询 Display 就绪 |
| 触摸链路 | 跨层序列化开销 | NativeTouchStruct 零拷贝 + 大小端转换 |
| 黑屏故障 | 用户手动重启 | 2 分钟自愈 + 每日 3 次限频防循环 |
| 触摸帧率 | 降帧状态按下必卡 | ACTION_DOWN 提频 3 线程,交互保 60fps |
复盘
- 跨进程/跨引擎系统的故障大头在时序而不是逻辑。绑定状态机本身不复杂,复杂的是承认"Surface 就绪"和"引擎就绪"谁先来都行,代码必须两种顺序都正确。
- 健康检查要看结果不看心跳。引擎活着不代表画面正常,FrameContentMonitor 直接采样像素哈希,把"黑屏但进程健康"这类盲区变成了可自愈的故障。这套思路和 Crash 治理 里"监控要落在用户可感知的现象上"一脉相承。
- 自愈必须带限频。没有每日 3 次的上限,自愈机制本身就是最大的故障源。
- fail-safe 默认值比完善的信号链更重要:上游信号不来时,SrMain 预填全屏前台让主屏触摸照常工作——用户无感,才是最好的降级。
- 触摸路由与手势的组合细节(RoutePriority、手势策略树)和 手势系统 是同一套交互体系的上下半场,JNI 传输侧的原则则和 Native-Unity 信号链路 一致:高频数据零拷贝、调用方式比业务逻辑先优化。