Flutter AndroidView 视频渲染:一次 Surface 生命周期问题的排查与改造
排查 AndroidView + SurfaceView 在横竖屏切换与路由进出时触发的引擎层 getWidth 空指针崩溃,记录向 TextureView 双分支桥接改造的判断过程与边界。
目录
在 Flutter Android 项目中接入不支持直接输出纹理 ID(TextureRegistry)、必须传入原生视图容器的第三方音视频 SDK 时,通常只能通过 AndroidView(PlatformView)把原生渲染控件嵌进 Widget 树。
此前在一个包含首页小窗预览、全屏横竖屏旋转、截图录像与二级设置页跳转的视频模块里,初始桥接方案选用了 Android 原生开发里常见的 SurfaceView。日常单次点开播放正常,但在真机(Android 13 / 14)反复执行“全屏旋转横屏 → 推入竖屏设置页 → 返回全屏页”或频繁切换预览卡片时,偶发出现应用闪退。
1. 平台视图 resize 链路的崩溃现象
查看崩溃日志可以发现,堆栈中不包含业务侧 Kotlin 代码,空指针直接抛在 Flutter Engine 的平台视图渲染目标上:
java.lang.NullPointerException: Attempt to invoke interface method ...
at io.flutter.plugin.platform.SurfaceProducerPlatformViewRenderTarget.getWidth(SurfaceProducerPlatformViewRenderTarget.java:...)
at io.flutter.plugin.platform.VirtualDisplayController.resize(VirtualDisplayController.java:...)
at io.flutter.plugin.platform.PlatformViewsController.resize(PlatformViewsController.java:...)
结合复现操作与上述调用链,可以定位出问题原因:
SurfaceView并不是普通的 AndroidView。它在窗口底层拥有一块独立于普通视图树之外的Surface(相当于在视图层上留出透明区域直接送显),这块Surface的创建、尺寸变更与销毁与窗口状态强绑定,和 Flutter 侧的布局动画节奏并不一致。- 当页面同时发生**横竖屏方向切换 + 路由压栈/出栈 +
AndroidView尺寸重算(resize)**时,Flutter 框架仍在向平台视图控制器下发尺寸调整指令,而底层对应的渲染目标或Surface在状态交错窗口期已经失效,最终在引擎层触发空指针崩溃。
2. 改造前核实 SDK 的 Surface 绑定接口
既然原因在于 SurfaceView 独立 Surface 与 AndroidView 频繁布局变化的生命周期竞态,一个自然的改进方向是改用与普通 View 树同层合成、能平滑参与缩放裁剪与旋转过渡的 TextureView。
但在修改桥接层之前,需要先查清第三方播放器 SDK 的渲染绑定接口签名。
不少年代较早的硬件或监控类原生 SDK 只暴露了 setSurfaceHolder(SurfaceHolder),内部绑定了 SurfaceView.getHolder();如果是这种情况,外部无法直接接入 TextureView。当时先通过反编译与字节码签名核对了该第三方 MediaPlayer 的公开方法:
// 核对 SDK 播放器类暴露的渲染绑定签名
public void setSurfaceHolder(android.view.SurfaceHolder holder);
public void setSurface(android.view.Surface surface);
确认它同时提供了直接接收 android.view.Surface 的重载后,用 TextureView 的 SurfaceTexture 构造 Surface 传入才具备可行性。
3. 双分支桥接改造:保留 SurfaceView 回退与独立就绪判断
在实际改造时,没有直接删除原有的 SurfaceView 代码,而是把原生 PlatformView 改成了可通过 creationParams 切换的双分支结构:默认走 TextureView,同时完整保留 SurfaceView 作为配置回退路径。如果后续在特定机型的硬解码、截图或受保护画面上遇到兼容差异,Dart 侧调整参数即可切换对比。
改造时需要特别注意 surface ready(渲染面就绪)的状态判断:不能只把控件从 SurfaceView 换成 TextureView,而挂起待播指令的判断条件仍保留旧的 surfaceHolder != null。
精简后的原生桥接核心结构如下(Kotlin):
enum class RenderViewType { TEXTURE_VIEW, SURFACE_VIEW }
class NativeVideoPlatformView(
context: Context,
creationParams: Map<String, Any?>?,
private val player: ThirdPartyMediaPlayer,
) : PlatformView {
private val renderType = when (creationParams?.get("renderView") as? String) {
"surfaceview" -> RenderViewType.SURFACE_VIEW
else -> RenderViewType.TEXTURE_VIEW
}
private var boundSurface: Surface? = null
private var boundHolder: SurfaceHolder? = null
private var pendingPlayUrl: String? = null
// 按当前渲染分支独立判断底层渲染面是否已就绪
private val isRenderTargetReady: Boolean
get() = when (renderType) {
RenderViewType.TEXTURE_VIEW -> boundSurface?.isValid == true
RenderViewType.SURFACE_VIEW -> boundHolder?.surface?.isValid == true
}
private val rootView: View = when (renderType) {
RenderViewType.TEXTURE_VIEW -> TextureView(context).apply {
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) {
bindTextureSurface(st)
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) = Unit
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean {
player.setSurface(null)
boundSurface?.release()
boundSurface = null
return true
}
override fun onSurfaceTextureUpdated(st: SurfaceTexture) = Unit
}
// 关键细节:若注册监听前 TextureView 已经完成附着,需立即补绑一次当前 SurfaceTexture
if (isAvailable) {
surfaceTexture?.let(::bindTextureSurface)
}
}
RenderViewType.SURFACE_VIEW -> SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
boundHolder = holder
player.setSurfaceHolder(holder)
flushPendingPlayIfReady()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, w: Int, h: Int) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) {
player.setSurfaceHolder(null)
boundHolder = null
}
})
}
}
private fun bindTextureSurface(st: SurfaceTexture) {
boundSurface?.release()
val surface = Surface(st)
boundSurface = surface
player.setSurface(surface)
flushPendingPlayIfReady()
}
fun requestPlay(url: String) {
if (!isRenderTargetReady) {
pendingPlayUrl = url
return
}
pendingPlayUrl = null
player.startPlay(url)
}
private fun flushPendingPlayIfReady() {
val url = pendingPlayUrl ?: return
if (isRenderTargetReady) {
pendingPlayUrl = null
player.startPlay(url)
}
}
override fun getView(): View = rootView
override fun dispose() {
pendingPlayUrl = null
player.stopPlay()
player.setSurface(null)
player.setSurfaceHolder(null)
boundSurface?.release()
boundSurface = null
boundHolder = null
}
}
里面有两个写代码时很容易漏掉的边界:
- 补绑已就绪的
SurfaceTexture:在某些视图复用或快速挂载时序下,设置surfaceTextureListener时textureView.isAvailable已经为true,不会再补发一次onSurfaceTextureAvailable;漏掉if (isAvailable)分支会导致画面一直处于挂起黑屏状态。 - 显式
release()自建的Surface包装对象:Surface(surfaceTexture)是桥接层手动新建的 Native 句柄包装,在onSurfaceTextureDestroyed和PlatformView.dispose()里都必须先让播放器解绑setSurface(null)再调用release(),避免底层 BufferQueue 泄漏。
4. 修复前后对照与选型适用边界
在当时的 Flutter 版本、播放器 SDK 与测试真机样本上,将默认分支切到 TextureView 后重新跑完下面整套回归路径:
回归验证项 SurfaceView 原路径 TextureView 默认路径
------------------------------------------------------------------------------------------------
1. 首页小窗预览卡片反复开关与圆角裁剪 切换偶发黑闪,圆角易穿透 过渡平滑,正常跟随视图裁剪
2. 全屏页横竖屏连续切换 + push 设置页返回 偶发 getWidth 空指针闪退 连续切换稳定,未再复现该崩溃
3. App 退后台锁屏再回前台恢复拉流 偶发旧 Surface 失效黑屏 按 onSurfaceTextureAvailable 正常重绑
4. 播放中截图、本地录像与双向语音对讲 正常 正常(与回退分支表现一致)
最后也需要明确这条经验的适用边界,不宜将单次排障结论直接推广为“所有视频场景都应使用 TextureView”:
- 优先考虑外接纹理(
TextureWidget +SurfaceTextureEntry):如果播放器本身由项目自主控制(如ExoPlayer/Media3)且无需在原生层叠加第三方自带控件,直接注册 FlutterTexture绕开PlatformView,渲染开销和稳定性都更好;只有在必须使用AndroidView承载第三方封装的原生视图或强耦合原生层时,才涉及本文的取舍。 TextureView的额外开销与限制:相比SurfaceView可由硬件合成器(Hardware Composer)走独立 Overlay 通道送显,TextureView需要走 GPU 纹理拷贝参与应用 UI 合成,在 4K 高帧率长时播放下功耗与内存带宽开销通常略高;此外,涉及 DRM 数字版权保护(要求SurfaceView.setSecure(true))的流媒体内容,往往在系统层要求使用SurfaceView。- 平台视图合成模式随 Flutter Engine 持续演进:不同版本的 Flutter Engine 在 Android API 级别上会在 Virtual Display、Texture Layer Hybrid Composition(TLHC)与 Hybrid Composition 之间选择不同路径。遇到类似崩溃时,先根据堆栈确认是否属于渲染目标
resize竞态,核实 SDK 的setSurface支持情况,再通过保留回退的双分支在目标机型上实测验证,会比单纯依赖固定选型更稳妥。