【DFX系列】Flutter 鸿蒙应用外接纹理介绍及问题定位

在我们调试Flutter 鸿蒙应用过程中,如果遇到视频播放画面黑屏卡顿,相机预览画面不更新,动画卡等异常场景...那么今天的这篇关于外接纹理异常的专题应该能帮助到你!

一、外接纹理概念

外接纹理(External Texture) :是 Flutter Engine 提供的一种外部图像数据接入机制 ,允许鸿蒙原生侧 产生的 GPU 图像帧直接注册到 Flutter 渲染管线,Flutter 通过Texture Widget在 Dart 层进行布局、变换、叠加渲染,而不是把图像像素拷贝到 Dart 内存里。

外接纹理与PlatformView 的区别:

外接纹理:原生输出 GPU 纹理帧,由 Flutter 统一合成 ,属于 Flutter LayerTree 里的TextureLayer。支持 Flutter 的动画、旋转、缩放、圆角、透明度叠加;z 轴和 Flutter UI 完全融合。

PlatformView:原生是独立的 XComponent 窗口,Flutter 只是 "挖个洞" 把原生视图盖在 Flutter 上面,叠加、动画、裁剪会有各种边界问题。

二、外接纹理注册流程

​如果你的日志显示如下,代表就成功了:

css 复制代码
I Flutter:  RegisterExternalTexture api type 2 texture_id 1 
I Flutter: OH_NativeImage_AcquireNativeWindow() success 
I Flutter: OH_NativeImage_GetSurfaceId() success, surfaceId = 12345678

注销的时候,必须先停生产者,再注销纹理。如果代码顺序搞反了就会出现崩溃问题:

scss 复制代码
// 正确顺序
stopProducer();                         
// 1. 先停:暂停视频 / 相机
textureRegistry.unregisterTexture(textureId); 
// 2. 后注销
// 错误顺序:先注销,生产者还在写入 → 崩溃

可以基于以下代码加深理解:

ArtTS侧:

typescript 复制代码
/*
 * 外接纹理注册演示插件(ArkTS 侧)
 *
 * 流程与引擎源码的对应:
 *   registerTexture(textureId)
 *     → 引擎内: OH_NativeImage_Create + AcquireNativeWindow + GetSurfaceId
 *     → 返回 SurfaceTextureEntry, getSurfaceId() 即"给生产者用的地址"
 *   AVPlayer 把 surfaceId 设上后, 解码出的每一帧都写进这条传送带
 *   引擎收到帧回调(OnNativeImageFrameAvailable)后通知 Flutter 重绘 Texture 控件
 *
 * 写法参照本机 video_player_ohos / camera_ohos 官方插件实现。
 */
import { MethodCall, MethodCallHandler, MethodChannel, MethodResult } from '@ohos/flutter_ohos';
import { FlutterPlugin, FlutterPluginBinding } from '@ohos/flutter_ohos/src/main/ets/embedding/engine/plugins/FlutterPlugin';
import { TextureRegistry } from '@ohos/flutter_ohos/src/main/ets/view/TextureRegistry';
import media from '@ohos.multimedia.media';
import { BusinessError } from '@kit.BasicServicesKit';
const TAG: string = 'NativeVideoTexture';
/** 一路视频 = 一个生产者(AVPlayer) + 一条传送带(注册进引擎的外接纹理) */
class VideoTextureEntry {
  textureId: number = -1;
  surfaceId: string = '';
  avPlayer: media.AVPlayer | null = null;
  releasePending: boolean = false; // AVPlayer.release 是异步的, 记住"谁先谁后"
}
export class NativeVideoTexturePlugin implements FlutterPlugin, MethodCallHandler {
  private static readonly CHANNEL = 'demo.native_video/texture';
  private channel: MethodChannel | null = null;
  private textureRegistry: TextureRegistry | null = null;
  private entries: Map<number, VideoTextureEntry> = new Map();
  // ---------- 插件生命周期 ----------
  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.textureRegistry = binding.getTextureRegistry();
    this.channel = new MethodChannel(binding.getBinaryMessenger(), NativeVideoTexturePlugin.CHANNEL);
    this.channel.setMethodCallHandler(this);
  }
  onDetachedFromEngine(binding: FlutterPluginBinding): void {
    this.channel?.setMethodCallHandler(null);
    this.channel = null;
    this.textureRegistry = null;
  }
  // ---------- MethodChannel 分发 ----------
  onMethodCall(call: MethodCall, result: MethodResult): void {
    switch (call.method) {
      case 'create':
        // uri 例: 'https://xxx.mp4'
        this.create(call.argument('uri') as string, result);
        break;
      case 'play':
        this.play(call.argument('textureId') as number, result);
        break;
      case 'pause':
        this.pause(call.argument('textureId') as number, result);
        break;
      case 'dispose':
        this.dispose(call.argument('textureId') as number, result);
        break;
      default:
        result.notImplemented();
    }
  }
  // ---------- 核心: 注册三步 ----------
  private async create(uri: string, result: MethodResult): Promise<void> {
    const registry = this.textureRegistry;
    if (registry === null) {
      result.error('NO_REGISTRY', 'TextureRegistry not ready', null);
      return;
    }
    const entry = new VideoTextureEntry();
    // ★ 注册三步(引擎内完成 ①创建OH_NativeImage ②取NativeWindow ③取surfaceId ④注册到引擎)
    entry.textureId = registry.getTextureId();
    const surfaceEntry = registry.registerTexture(entry.textureId); // ①②③④ 一次完成
    entry.surfaceId = surfaceEntry.getSurfaceId().toString();      // 生产者要用的"地址"
    console.info(`${TAG} texture_id=${entry.textureId} surfaceId=${entry.surfaceId}`);
    // 生产者: AVPlayer 解码每一帧都写入 surfaceId 对应的传送带
    try {
      const avPlayer = await media.createAVPlayer();
      entry.avPlayer = avPlayer;
      avPlayer.on('stateChange', async (state: string) => {
        switch (state) {
          case 'idle':
            avPlayer.url = uri; // 设置源后进入 initialized
            break;
          case 'initialized':
            // ★ surfaceId 必须在 prepare 之前设置(官方 video_player_ohos 同款时机),
            //   晚了生产者没有窗口可写 → 黑屏, 日志会出现 No DlImage available
            avPlayer.surfaceId = entry.surfaceId;
            avPlayer.prepare();
            break;
          case 'prepared':
            // 画面尺寸确定后设置生产者窗口尺寸, 防拉伸/黑边
            // (画面尺寸变化时同样要调用, 配合 notifyTextureResizing)
            registry.setTextureBufferSize(entry.textureId, avPlayer.width, avPlayer.height);
            avPlayer.play();
            break;
          case 'released':
            // AVPlayer 真正释放完毕后才注销纹理(先停生产者的"完成信号")
            if (entry.releasePending) {
              registry.unregisterTexture(entry.textureId); // 传送带拆除
              this.entries.delete(entry.textureId);
            }
            break;
          default:
            break;
        }
      });
      this.entries.set(entry.textureId, entry);
      result.success(entry.textureId); // 把 textureId 交给 Dart 侧的 Texture 控件
    } catch (e) {
      // 创建失败也要把已注册的纹理拆掉, 否则引擎里留死纹理
      registry.unregisterTexture(entry.textureId);
      result.error('CREATE_FAILED', `${e}`, null);
    }
  }
  private play(textureId: number, result: MethodResult): void {
    const entry = this.entries.get(textureId);
    entry?.avPlayer?.play();
    result.success(true);
  }
  private pause(textureId: number, result: MethodResult): void {
    const entry = this.entries.get(textureId);
    entry?.avPlayer?.pause();
    result.success(true);
  }
  // ---------- 释放: 铁律 = 先停生产者, 再注销纹理 ----------
  private dispose(textureId: number, result: MethodResult): void {
    const entry = this.entries.get(textureId);
    if (entry === undefined || entry.avPlayer === null) {
      // 播放器已不在, 直接拆传送带
      this.textureRegistry?.unregisterTexture(textureId);
      this.entries.delete(textureId);
      result.success(true);
      return;
    }
    // 先停生产者: release 是异步的, 真正 released 后才在 stateChange 回调里
    // unregisterTexture ------ 顺序反了会出现"纹理释放后还在访问"崩溃
    entry.releasePending = true;
    entry.avPlayer.release().catch((err: BusinessError) => {
      console.error(`${TAG} release failed: ${err.code} ${err.message}`);
      this.textureRegistry?.unregisterTexture(textureId);
      this.entries.delete(textureId);
    });
    result.success(true);
  }
}

Dart侧:

dart 复制代码
/// 外接纹理演示 ------ Dart 侧通道封装
///
/// ArkTS 侧完成注册后只回传一个 textureId,
/// Dart 侧用它构建 Texture 控件, 画面即从原生传送带流入 Flutter。
library;
import 'dart:async';
import 'package:flutter/services.dart';
/// 一路原生视频纹理。
///
/// 用法:
/// ```dart
/// final video = await NativeVideoTexture.create(uri);
/// video.play();
/// Texture(textureId: video.textureId);
/// video.dispose(); // 必须调用: 先停生产者再注销(插件内已保证顺序)
/// ```
class NativeVideoTexture {
  static const MethodChannel _channel =
      MethodChannel('demo.native_video/texture');
  /// 注册一路纹理并创建 AVPlayer 生产者, 返回可用的 [NativeVideoTexture]。
  static Future<NativeVideoTexture> create(String uri) async {
    final textureId =
        await _channel.invokeMethod<int>('create', {'uri': uri});
    if (textureId == null) {
      throw PlatformException(
          code: 'CREATE_FAILED', message: 'textureId 为空, 注册失败');
    }
    return NativeVideoTexture._(textureId);
  }
  NativeVideoTexture._(this.textureId);
  /// 传给 Texture 控件的 id。
  final int textureId;
  bool _disposed = false;
  Future<void> play() =>
      _channel.invokeMethod('play', {'textureId': textureId});
  Future<void> pause() =>
      _channel.invokeMethod('pause', {'textureId': textureId});
  /// 释放: 插件内先 release 生产者(AVPlayer), 等 released 后再注销纹理。
  ///
  /// 顺序反了 = "快递箱扔了还有人往里放东西" → 纹理访问崩溃。
  Future<void> dispose() async {
    if (_disposed) return;
    _disposed = true;
    await _channel.invokeMethod('dispose', {'textureId': textureId});
  }
}
php 复制代码
/// 外接纹理演示页:原生视频画面显示在 Flutter 里
///
/// 页面结构: 按钮触发注册 → Texture 控件展示 → 退出时按"先停生产者再注销"释放。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'native_video_texture.dart';
class TextureDemoPage extends StatefulWidget {
  const TextureDemoPage({super.key, this.uri = 'https://media.w3.org/2010/05/sintel/trailer.mp4'});
  /// 换成你自己的视频地址
  final String uri;
  @override
  State<TextureDemoPage> createState() => _TextureDemoPageState();
}
class _TextureDemoPageState extends State<TextureDemoPage> {
  NativeVideoTexture? _video;
  String? _error;
  Future<void> _create() async {
    setState(() => _error = null);
    try {
      final video = await NativeVideoTexture.create(widget.uri);
      setState(() => _video = video);
    } on PlatformException catch (e) {
      // 对应黑屏排查图第 1 步: 日志里搜不到 RegisterExternalTexture
      // 说明注册链路就断了, 看这里拿到什么错误
      setState(() => _error = '${e.code}: ${e.message}');
    }
  }
  @override
  void dispose() {
    // 铁律: 先停生产者(AVPlayer.release), 再注销纹理(unregisterTexture)
    // ------ 顺序在插件 dispose 内部保证, 这里只管调用
    _video?.dispose();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    final video = _video;
    return Scaffold(
      appBar: AppBar(title: const Text('外接纹理演示')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (video != null)
              // ★ 画面入口: textureId 对上, 传送带上的帧就会画进这一块
              AspectRatio(
                aspectRatio: 16 / 9,
                child: Texture(textureId: video.textureId),
              )
            else if (_error != null)
              Padding(
                padding: const EdgeInsets.all(24),
                child: Text('创建失败: $_error',
                    style: const TextStyle(color: Colors.red)),
              )
            else
              const Text('点击下方按钮注册纹理并开始播放'),
            const SizedBox(height: 24),
            if (video != null)
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  IconButton(
                      onPressed: video.play, icon: const Icon(Icons.play_arrow)),
                  IconButton(
                      onPressed: video.pause, icon: const Icon(Icons.pause)),
                ],
              )
            else
              FilledButton(onPressed: _create, child: const Text('创建纹理 + 播放')),
          ],
        ),
      ),
    );
  }
}

EntryAbility:

javascript 复制代码
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import { NativeVideoTexturePlugin } from '../plugins/NativeVideoTexturePlugin';
export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    GeneratedPluginRegistrant.registerWith(flutterEngine)
    flutterEngine.getPlugins()?.add(new NativeVideoTexturePlugin())   // ← 加这行
  }
}

三、外接纹理在哪些场景会用到,常见的问题有哪些?

以下业务场景通常会用到外接纹理,场景:(1)视频播放(2)相机预览(3)动画播放(4)WebView(5)直播 SDK等

可以按以下现象初步判断下问题在哪:

看到的现象 可能的原因
视频、相机画面黑屏 纹理创建失败 / 生产者没产出帧
画面第一帧后不更新 帧闸门开启 / Surface 销毁 / onInactive 被误触发
画面卡顿、丢帧 消费过慢 / 跳帧
画面拉伸、变形 尺寸变更未收敛
应用闪退 纹理释放后还在访问
退后台后还在耗电 可见区域监控未启用

四、黑屏或不显示 如何排查

​常见问题原因及修复方法

原因 怎么修
纹理未注册 检查注册代码
NativeImage 创建失败 检查系统资源
生产者没产出帧 确保视频已开始播放、相机已启动
Texture 控件 size 为 0 检查 Widget 布局
surfaceId 不对 确认 surfaceId 传递正确

五、卡顿 / 丢帧 如何排查

​四步进行排查:

(1)搜 skip one frame(slow consumer)搜到说明消费过慢引擎在跳帧,需要检查 Raster 线程是否被其他任务阻塞,同时检查 buffer_queue_size 是否过小。

(2)搜 MarkNewFrameAvailable avail-seq:avail-seq 不增长,是生产者没产出帧;avail-seq 增长但 paint-seq 不增长,那就是 Raster 线程卡了。

(3)搜 GpuReclaim:搜到说明 GPU 回收导致中断了。

(4)搜 get error buffer queue size:搜到说明缓冲队列异常(超过 100)。

六、拉伸 / 变形 如何排查

搜 size change 相关日志:

日志 含义
size change took N frames 尺寸变更在 N 帧内完成,正常
stop size change state: frame > 10 尺寸变更超过 10 帧,异常
direct release size changed buffer 缓冲尺寸变了但绘制区域没变,防拉伸

修复方法:确保 setTextureBufferSize 和 notifyTextureResizing 调用一致。

七、以下为日志关键字速查表

关键字 含义 程度
RegisterExternalTexture api type 纹理注册 ---
OH_NativeImage_Create() failed 创建失败 高
No DlImage available 无可绘制画面,黑屏 中
frame gate enabled, drain-only 后台帧闸门开启 正常
skip one frame(slow consumer) 消费过慢跳帧 中
MarkNewFrameAvailable avail-seq 帧序号监控 ---
OnGrContextCreated texture_id GPU 上下文重建 ---
size change took N frames 尺寸变更完成 正常
PlatformViewVisibleAreaEventCallback 可见区域变化 ---
UnRegisterExternalTexture 纹理注销 ---
~OHOSExternalTexture 纹理析构 ---

外接纹理相关的内容比较多,本次主要是讲个概念和基本的排查方法,希望能帮助到大家~更进一步的排查方法就需要依赖工具了,相关专题最近也在进行规划,大家可以持续关注,后续会持续更新:

小伙伴们记得点赞+关注

关注 CPF-Flutter 社区

"AI再牛,技术不能丢"

相关推荐
梦想不只是梦与想6 小时前
HarmonyOS应用分层架构设计
harmonyos·分层架构·一次开发,多端部署
MardaWang8 小时前
当滚动逃离了框架 ——HarmonyOS Web 与原生混排滚动的冲突本质与解法
harmonyos·arkts·鸿蒙·deveco studio
tsqtsqtsq030910 小时前
DevEco Studio 介绍
harmonyos
传奇开心果编程12 小时前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
HwJack2013 小时前
【共创稿事节】HarmonyOS 7文旅展陈展厅大空间 3DGS 重建的分块策略与拼接踩坑
3d·华为·harmonyos
m0_7381858215 小时前
Flutter 鸿蒙化实战:media_info 适配 OpenHarmony,媒体信息与缩略图
flutter·华为·harmonyos·鸿蒙·媒体
m0_7381858216 小时前
Flutter 鸿蒙化实战:just_audio 适配 OpenHarmony,功能强大的播放器
flutter·华为·harmonyos·鸿蒙
翼辉cto16 小时前
Kotlin Multiplatform 三方库 SQLDelight 的 OpenHarmony 鸿蒙化适配实战
开发语言·kotlin·harmonyos
SuperHeroWu716 小时前
TraeCode 国内版接入 DevEco CLI:用官方知识开发鸿蒙应用
ai编程·harmonyos·知识库·trae·aicoding·skills·deveco cli