适合谁看

  • 正在维护鸿蒙 MethodChannel 代码的人

  • 觉得平台通道"能跑就行"的开发者

  • 想知道测试优先级为什么应该放在边界层的人

问题背景

平台通道层的问题,不一定会在"正常路径"出现。它更容易出问题的地方通常是:

边界场景

说明

影响

插件不存在

非鸿蒙平台或插件未注册

MissingPluginException

权限被拒绝

鸿蒙麦克风权限未授权

语音识别失败

返回值为空

原生侧返回 null 或空字符串

页面显示异常

Flutter 还没 ready

冷启动时原生先收到入口

导航请求丢失

原生侧先报错

鸿蒙引擎创建失败

页面卡死

这正是边界测试存在的理由。

项目中的真实场景

食界探味当前的关键通道层:

通道文件

对应鸿蒙插件

边界场景

speech_recognition_channel.dart

SpeechRecognitionPlugin.ets

权限拒绝、引擎创建失败

text_to_speech_channel.dart

TextToSpeechPlugin.ets

文本为空、引擎创建失败

intent_navigation_channel.dart

IntentNavigationPlugin.ets

Flutter 未 ready、pageId 非法

anti_peep_protection_channel.dart

AntiPeepProtectionPlugin.ets

系统开关未开、订阅失败

核心实现

一、场景 1:插件不存在——MissingPluginException

在非鸿蒙平台(Android/iOS/Web)上运行时,鸿蒙插件不存在。如果通道层没有处理,会直接抛 MissingPluginException

食界探味的处理:

// anti_peep_protection_channel.dart

static Future<void> _invoke(String method) async {
  try {
    await _channel.invokeMethod<void>(method);
  } on MissingPluginException {
    // 鸿蒙插件不可用 — 安全忽略
  } catch (e, stackTrace) {
    AppLogger.warning(
      'Anti-peep protection channel call failed: $method',
      e,
      stackTrace,
    );
  }
}
// intent_navigation_channel.dart

static Future<void> _consumePending() async {
  try {
    final payload = await _channel.invokeMethod<Object?>(
      'consumePendingNavigation',
    );
    // ...
  } on MissingPluginException {
    // 非鸿蒙平台 — 忽略
  } catch (e) {
    AppLogger.warning('consumePendingNavigation failed: $e');
  }
}

测试要点:

测试项

预期行为

非鸿蒙平台调用 _invoke()

不抛异常,安全返回

鸿蒙平台但插件未注册

捕获 MissingPluginException,记日志

鸿蒙平台插件正常

正常执行

二、场景 2:权限被拒绝——鸿蒙麦克风权限

语音识别需要鸿蒙麦克风权限。如果用户拒绝,原生侧会返回错误。

鸿蒙侧的处理:

// SpeechRecognitionPlugin.ets

private async handleStartListening(call: MethodCall, result: MethodResult): Promise<void> {
  const hasPermission = await this.requestMicrophonePermission();
  if (!hasPermission) {
    result.error('PERMISSION_DENIED', '麦克风权限被拒绝', null);
    return;
  }
  // 继续识别...
}

private async requestMicrophonePermission(): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  const permissions: Permissions[] = ['ohos.permission.MICROPHONE'];
  const context = getContext(this);
  const grantResult = await atManager.requestPermissionsFromUser(context, permissions);
  return grantResult.authResults.every(
    status => status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED
  );
}

Flutter 侧的处理:

// ai_explore_coordinator.dart

Future<void> startVoiceInput() async {
  state = state.copyWith(status: AiSessionStatus.listening);
  try {
    final text = await SpeechRecognitionChannel.startListening();
    if (text.isEmpty) {
      state = state.copyWith(
        status: AiSessionStatus.error,
        errorMessage: '未听清,请再说一次',
      );
      return;
    }
    await submitQuery(text);
  } catch (e) {
    AppLogger.error('[AI助手] 语音识别出错: $e');
    state = state.copyWith(
      status: AiSessionStatus.error,
      errorMessage: '语音识别出错,请手动输入',
    );
  }
}

测试要点:

测试项

预期行为

用户授权麦克风

正常识别

用户拒绝麦克风

返回错误,页面显示"语音识别出错"

权限弹窗期间用户切走

不崩溃,状态恢复

三、场景 3:返回值为空——原生侧返回 null

鸿蒙原生侧可能返回空值。如果 Flutter 侧不做校验,会导致页面显示异常。

语音识别的空值处理:

// speech_recognition_channel.dart

static Future<String> startListening({String language = 'zh-CN'}) async {
  final result = await _channel.invokeMethod<String>(
    'startListening',
    {'language': language},
  );
  return result ?? '';  // null 时空字符串
}

协调器的空值处理:

final text = await SpeechRecognitionChannel.startListening();
if (text.isEmpty) {
  state = state.copyWith(
    status: AiSessionStatus.error,
    errorMessage: '未听清,请再说一次',
  );
  return;
}

TTS 的空值处理:

// ai_explore_coordinator.dart

Future<void> speakText(String text) async {
  final cleaned = _stripForTts(text);
  if (cleaned.isEmpty) return;  // 清理后为空则不播报
  // 继续播报...
}

测试要点:

测试项

预期行为

原生返回 null

Flutter 收到空字符串,不崩溃

原生返回空字符串

协调器识别为空,显示提示

TTS 文本清理后为空

不调用播报,不崩溃

四、场景 4:初始化时机——Flutter 还没 ready

冷启动时,鸿蒙原生可能先收到入口请求,但 Flutter 引擎还没初始化。

鸿蒙侧的处理:

// IntentNavigationPlugin.ets

navigateToPage(pageId: string, dishId?: string): void {
  if (this.channel) {
    // Flutter 已 ready,直接推送
    this.channel.invokeMethod('onIntentNavigation', args);
  } else {
    // Flutter 未 ready,暂存为 pending
    IntentNavigationPlugin.pendingNavigation = { pageId, dishId };
  }
}

Flutter 侧的处理:

// intent_navigation_channel.dart

static void init(GoRouter router) {
  _router = router;
  _channel.setMethodCallHandler((call) async {
    // 监听实时推送
  });
  _consumePending();  // 主动消费 pending
}

测试要点:

测试项

预期行为

冷启动时原生先收到入口

写入 pending,Flutter 初始化后消费

热启动时原生收到入口

直接推送,Flutter 实时处理

pending 被消费后再次调用

返回 null,不重复处理

五、场景 5:原生侧报错——引擎创建失败

鸿蒙 TTS 或 ASR 引擎可能创建失败(资源不足、系统异常等)。

鸿蒙侧的处理:

// TextToSpeechPlugin.ets

private async handleSpeak(call: MethodCall, result: MethodResult): Promise<void> {
  const text = call.argument('text') as string;
  if (!text || text.length === 0) {
    result.error('INVALID_ARGUMENT', '播报文本不能为空', null);
    return;
  }

  this.pendingResult = result;
  try {
    await this.createEngine();
    this.setupListenerAndSpeak(text);
  } catch (err) {
    this.pendingResult = null;
    result.error('TTS_ERROR', `文本转语音启动失败: ${error.message}`, null);
  }
}

Flutter 侧的处理:

// ai_assistant_screen.dart

void _toggleSpeak(String text) async {
  if (_isSpeaking) {
    await TextToSpeechChannel.stop();
    setState(() => _isSpeaking = false);
  } else {
    setState(() => _isSpeaking = true);
    try {
      await TextToSpeechChannel.speak(text);
    } catch (_) {}  // 出错不影响页面
  }
}

测试要点:

测试项

预期行为

TTS 引擎创建成功

正常播报

TTS 引擎创建失败

返回错误,页面不崩溃

TTS 引擎创建失败后重试

可以重新创建

关键代码位置

文件

边界场景

app/lib/core/platform/speech_recognition_channel.dart

权限拒绝、空返回

app/lib/core/platform/text_to_speech_channel.dart

文本为空、引擎失败

app/lib/core/platform/intent_navigation_channel.dart

Flutter 未 ready、pageId 非法

app/lib/core/platform/anti_peep_protection_channel.dart

插件不存在、系统开关未开

app/ohos/entry/src/main/ets/plugins/*.ets

鸿蒙原生异常处理

5 个边界场景的完整测试矩阵

场景

鸿蒙侧处理

Flutter 侧处理

测试项

插件不存在

N/A

MissingPluginException catch

非鸿蒙平台不崩溃

权限拒绝

requestMicrophonePermission

catch + 显示错误提示

用户拒绝后不崩溃

返回值为空

result.success('')

?? '' + isEmpty 检查

空值不导致异常

Flutter 未 ready

pendingNavigation 缓存

_consumePending() 消费

冷启动不丢失入口

原生引擎失败

result.error()

try-catch + catch(_)

引擎失败不卡死页面

为什么边界测试比单元测试更重要

维度

单元测试

边界测试

覆盖范围

正常路径

异常路径

发现问题

逻辑错误

环境依赖问题

测试成本

低(mock)

中(需要真实环境)

价值

验证逻辑正确

验证鲁棒性

适合场景

纯 Dart 逻辑

平台通道、原生交互

平台通道层的代码量可能不多,但它承载的是跨语言、跨运行时的通信。这种代码的正常路径通常很简单,但边界路径非常复杂。所以边界测试的价值远高于单元测试。

常见坑

  • 只测正常调用,不测异常和空返回 — 真实用户会拒绝权限、网络会断、引擎会失败

  • 通道层出错后没有日志 — 排查问题时没有线索

  • 把页面回退逻辑和通道异常处理混在一起 — 应该分层处理

  • 以为平台通道很薄,就不值得测试 — 薄不代表简单,边界路径很多

  • 没有处理 MissingPluginException — 非鸿蒙平台直接崩溃

  • 没有处理引擎创建失败 — 页面卡死

  • pending 没有清空 — 同一导航被多次处理

可复用模板

鸿蒙平台通道边界测试清单

每个平台通道必须测试:
  □ MissingPluginException(非鸿蒙平台)
  □ 权限拒绝(鸿蒙麦克风/存储权限)
  □ 返回值为空(null / 空字符串)
  □ 初始化时机(Flutter 未 ready)
  □ 原生引擎失败(TTS/ASR 创建失败)
  □ 重复调用(连续点击按钮)
  □ 页面退出时的清理(dispose)

鸿蒙通道层鲁棒性模板

static Future<T> safeInvoke<T>(String method, Map<String, dynamic>? args) async {
  try {
    final result = await _channel.invokeMethod<T>(method, args);
    return result ?? '' as T;
  } on MissingPluginException {
    // 非鸿蒙平台,返回默认值
    return '' as T;
  } catch (e) {
    AppLogger.warning('Channel call failed: $method', e);
    return '' as T;
  }
}

鸿蒙插件层鲁棒性模板

private async handleSpeak(call: MethodCall, result: MethodResult): Promise<void> {
  const text = call.argument('text') as string;
  if (!text || text.length === 0) {
    result.error('INVALID_ARGUMENT', '文本为空', null);
    return;
  }

  this.pendingResult = result;
  try {
    await this.createEngine();
    this.setupListenerAndSpeak(text);
  } catch (err) {
    this.pendingResult = null;
    result.error('TTS_ERROR', `启动失败: ${err.message}`, null);
  }
}

本篇总结

鸿蒙平台通道是 Flutter 和原生之间最典型的边界层。这层最需要边界测试,而不是只看正常路径:

  1. 插件不存在 — MissingPluginException 必须 catch

  2. 权限拒绝 — 鸿蒙麦克风权限必须处理

  3. 返回值为空 — null 和空字符串必须校验

  4. 初始化时机 — 冷启动 pending 必须消费

  5. 原生引擎失败 — TTS/ASR 失败不能卡死页面

把这层测稳,很多系统能力接入问题会少掉一大半。边界测试不是重测试,而是检查最可能出错的地方。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐