uniapp SurfaceView层级叠加
uni-app UTS 原生组件实战:Android SurfaceView 与 nvue 页面层级叠加终极方案
一个 uni-app nvue 项目中,通过 UTS 原生组件模式解决阿里云推流 SDK 摄像头预览与自定义 UI 叠加的完整踩坑记录。
背景
项目是一个在线教育直播 App,教师端需要摄像头预览画面在底层,自定义 UI 控件(按钮、状态提示、遮罩)浮在上方。iOS 上几分钟就搞定了,Android 上折腾了好几周。
技术栈:uni-app(nvue 纯原生渲染) + 阿里云直播推流 SDK(AlivcLivePusher 7.11)+ UTS 插件体系。
最开始不知道 UTS 还有组件模式和 API 模式的区别,看文档照着 export function 的套路就把阿里云原生 SDK 接上了。功能跑起来才发现画面和 UI 怎么都对不齐——要么摄像头画面盖住所有按钮,要么按钮有了但画面黑屏。绕了一圈才搞清楚,API 模式压根拿不到 nvue 页面的 View 树,全是在 Window 层面硬刚 SurfaceFlinger,根本不在一个维度。后面换成 UTS 组件模式,NVLoad() 一把梭把 View 挂进页面树里,才算真正解决了问题。
问题的本质
Android 有一个特殊的玩家叫 SurfaceView。
普通 UI 控件(Button、Text、View)的绘制流程是:App → View 树 → SurfaceFlinger 合成 → 屏幕。大家走同一条路,z-order 可控。
SurfaceView 不一样——它虽然有一个 View 占位符挂在 View 树里,但实际的画面数据走了独立管线:App → 独立 Surface → SurfaceFlinger 直接合成到屏幕。View 树里那个占位符是个”洞”,洞里的内容不归 View 树管。
所以就有了这个坑:同一个容器里的 SurfaceView 和普通 UI,你没办法用 z-order / bringToFront() / elevation 来控制上下关系。它们走的根本不是同一条渲染通道。
1 | ┌─────────────────────────────────────────┐ |
更致命的是,uni-app 的 nvue 页面跑在 weex 引擎上,SurfaceView 挂在哪里、跟谁做兄弟,直接决定了画面能不能和 UI 正确叠加。
方案一:UTS 插件 API 模式(失败)
思路
在 UTS 插件(index.uts,不是 index.vue)中用 export function 暴露 startPreview()。函数内部创建 SurfaceView,用 Activity.findViewById(android.R.id.content) 拿到 Activity 的根容器,addView(sv, 0) 把 SurfaceView 插到最底层。
代码大概长这样
1 | export function startPreview(): boolean { |
结果
三种情况,全部不行:
setZOrderOnTop(false)→ View 层的 nvue UI 把 SurfaceView 的洞完全盖住,黑屏,连摄像头画面都看不到setZOrderOnTop(true)→ 摄像头画面确实出来了,但把 所有 UI 盖住了,按钮、文字全看不见- 把页面背景改成透明 → 透明区域看到的不是摄像头,而是上一个页面的残留渲染,像鬼影一样
为什么失败
android.R.id.content 是 Activity 的根 Window 容器。weex 引擎的 nvue 页面虽然在这个 Window 里渲染,但和 SurfaceView 之间隔着一层 SurfaceFlinger 的跨体系合成。简单说:SurfaceView 插到了 Window 级别,UI 在 weex 级别,不在同一个战场。
方案二:livecamera AAR 桥接(失败)
思路
觉得直接在 UTS 里操作 SurfaceView 太底层、不可控,于是封装了一个 AAR(LivePushUI → livepushui-debug.aar),里面有个 PushPreviewView extends FrameLayout,内部管理 SurfaceView 的创建和生命周期。UTS 插件调用 PushPreviewView.createAndAttach(activity)。
结果
一样死。
为什么失败
换壳没换核。PushPreviewView.createAndAttach() 的 addView 目标还是 android.R.id.content——Window 级别。AAR 只是给方案一套了层皮,插入位置没变,问题当然也没变。
方案三:webview 透明穿透(失败)
思路
把 nvue 页面的 webview 背景设透明,让 SurfaceView 的画面”透”上来。
结果
透上来的不是预览画面,是上一个页面的鬼影。
为什么失败
nvue 的 weex 渲染层本身有不透明底色,不支持真正的全透明。而且就算透明了,SurfaceView 的合成位置是在 nvue View 树之后的,透明能看到的只是 View 层下面 Window 的背景,不是 SurfaceView 的画面。
方案四:startPreview(FrameLayout) 黑屏(失败)
思路
翻阅 SDK 文档发现 AlivcLivePusher 有两个 startPreview 重载:
startPreview(SurfaceView)— 传一个 SurfaceViewstartPreview(Context, FrameLayout, boolean)— 传一个 FrameLayout 给 SDK 托管
想着第二个签名更简单——不需要手动管理 SurfaceView,SDK 自动在 FrameLayout 里创建一切。在 UTS 组件中,NVLoad() 返回一个 FrameLayout,然后调 startPreview(context, layout, true)。
结果
黑屏。日志里 SDK 初始化成功,startPreview 没报错,但就是没有摄像头画面。
为什么失败
去翻了 SDK 的 Javadoc:
| 签名 | 模式 | 行为 |
|---|---|---|
startPreview(SurfaceView) |
Basic mode only | ✅ 打开摄像头 + 渲染画面 |
startPreview(Context, FrameLayout, bool) |
Interactive mode only | ❌ 不开摄像头,只提供交互容器 |
Interactive mode 那个根本不开摄像头。同名方法,行为完全不一样。不看文档光看方法名猜语义,白给。
方案五:UTS 组件模式(成功)
转机
四连跪之后回头看了下 UTS 的两种形态:
| 形态 | 目录入口 | 运行方式 |
|---|---|---|
| 插件 API 模式 | index.uts + export function |
独立线程,拿不到页面 View 树 |
| 组件模式 | index.vue + NVLoad() |
返回的 View 直接挂入 nvue 页面的 View 树 |
区别就一句话:组件模式的 NVLoad() 返回的 View,和 nvue 页面的 UI 是同一棵树上的兄弟。
原理
API 模式(失败):跨 Window 作战
1 | ┌── Activity Window ──────────────────────────┐ |
组件模式(成功):同 View 树做兄弟
1 | nvue 页面 View 树(同一个 Window) |
SurfaceView 虽然画面走独立管线,但它的 View 占位符在 View 树中是第一个子节点(index 0)。排在它后面的兄弟 View(index 1, 2, 3…)绘制时天然落在”洞”的上方。跟 z-order 没关系,纯粹是 View 树的绘制顺序。
核心实现
1. NVLoad — 创建容器
1 | NVLoad(): android.widget.FrameLayout { |
必须显式声明返回类型。黑色背景兜底——SurfaceView 还没渲染出画面的时候,黑色遮住底层,不会透出奇怪的东西。
2. SurfaceView 创建的时机
不能直接在 NVLoad 里 new SurfaceView + addView,这时候容器还没 attach 到 Window,surfaceCreated 回调打死不会触发。
等容器 layout 完(宽高非零)再动手。
1 | tryBuildSurfaceAndPreview(): void { |
3. 线程安全
跑通第一版后出现了一个诡异的闪退——画面和遮罩都正常,但不到一秒就崩。
查了一下:SDK 的回调(onFirstFramePreviewed、onPushStarted 等)跑在渲染线程,UTS 的 this.$emit() 必须在主线程。跨线程调 weex 桥,直接炸。
修:所有 SDK 回调里的 $emit 套一层 container.post() 切回主线程。
1 | this.pusher!.setLivePushInfoListener(new PusherInfoCb( |
4. 回调模式
UTS 官方示例用 UTSComponent<ViewType> 持有组件引用。但要在回调里同时碰 $emit、isPushing、container 好几个组件成员,用回调函数传参更顺手。
1 | class PusherInfoCb extends AlivcLivePushInfoListener { |
5. 页面端使用
1 | <template> |
几个编译坑
UTS 编译器有几个反直觉的地方:
| 问题 | 原因 | 修复 |
|---|---|---|
'onDropFrame' overrides nothing |
UTS 的 number 是浮点型,Java int 参数需用 Int 类型 |
number → Int |
Unresolved reference '$emit' |
owner: any 无法被编译器解析成员 |
改用回调函数构造函数模式 |
error.name() 无法调用 |
UTS 将 Java enum 的 name() 映射为属性而非方法 |
error.name() → error.name |
iOS 为什么没这事
iOS 摄像头预览用 AVCaptureVideoPreviewLayer,就是普通的 CALayer,挂在 UIView.layer 上。所有 UIView 和 CALayer 都在 Core Animation 的同一棵图层树里,z-order 就是 subviews 顺序,没什么特别的。
Android 的 SurfaceView 是特例——它是系统为了高性能视频渲染专门设计的”作弊通道”。画面不走 View 树渲染管线,直接通过 SurfaceFlinger 合成。这给了它性能优势,也给了它层级控制的噩梦。
最终架构图
1 | pusher-test/utssdk/app-android/index.vue |
事件流
1 | 页面加载 |
总结
Android SurfaceView 的层级问题,根上不是怎么调 z-order,是代码跑在哪一层。
- Window 层(插件 API 模式):无解
- View 树层(UTS 组件模式):通了



