客户端开发

Flutter AndroidView 视频渲染:一次 Surface 生命周期问题的排查与改造

排查 AndroidView + SurfaceView 在横竖屏切换与路由进出时触发的引擎层 getWidth 空指针崩溃,记录向 TextureView 双分支桥接改造的判断过程与边界。

约 15 分钟读完
目录
  1. 1. 平台视图 resize 链路的崩溃现象
  2. 2. 改造前核实 SDK 的 Surface 绑定接口
  3. 3. 双分支桥接改造:保留 SurfaceView 回退与独立就绪判断
  4. 4. 修复前后对照与选型适用边界

在 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 并不是普通的 Android View。它在窗口底层拥有一块独立于普通视图树之外的 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
  }
}

里面有两个写代码时很容易漏掉的边界:

  1. 补绑已就绪的 SurfaceTexture:在某些视图复用或快速挂载时序下,设置 surfaceTextureListener 时 textureView.isAvailable 已经为 true,不会再补发一次 onSurfaceTextureAvailable;漏掉 if (isAvailable) 分支会导致画面一直处于挂起黑屏状态。
  2. 显式 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”:

  • 优先考虑外接纹理(Texture Widget + SurfaceTextureEntry):如果播放器本身由项目自主控制(如 ExoPlayer / Media3)且无需在原生层叠加第三方自带控件,直接注册 Flutter Texture 绕开 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 支持情况,再通过保留回退的双分支在目标机型上实测验证,会比单纯依赖固定选型更稳妥。