【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再牛,技术不能丢"

相关推荐
三翼鸟数字化技术团队1 小时前
WiFi-DensePose × OpenHarmony 智慧家居融合
harmonyos
HarmonyOS_SDK1 小时前
AI 赋能 Push Kit 场景化消息开发,高效完成鸿蒙应用推送能力接入
harmonyos
大雷神2 小时前
【共创稿事节】HarmonyOS ArkGraphics 3D实操——做一个可暂停、可拖动的音箱场景动画
harmonyos
anthonyzhu3 小时前
MacOS27 x86限制引发的pod的问题处理
华为·harmonyos
贾伟康3 小时前
【HarmonyOS 7新能力|030】沉浸光感工程封装:把接入逻辑放进可维护的分层结构
harmonyos·arkts·arkui·harmonyos 7·交互动效
万物智能信息科技3 小时前
GPIO控制状态灯—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
人工智能·华为·开源·harmonyos·鸿蒙
威哥爱编程3 小时前
HarmonyOS 7 平行视界 EasyGo 实战:配置文件接入应用内分屏,1:2/2:1 随意切
harmonyos·arkts
恋猫de小郭3 小时前
Flutter GSoC 2026 提案进度解读,补上 DevTools、FFI 和原生平台的关键缺口
android·前端·flutter
威哥爱编程3 小时前
HarmonyOS 7 空间重建实战:3DGS 端侧重建从建模到商品展示
harmonyos·arkts