环信即时通讯 IM 为开发者提供高可靠、低时延、高并发、安全、全球化的通信云服务,帮助开发者快速构建端到端通信的场景。环信提供 SDK 和 RESTful API,支持同时在线人数无上限,聊天室亿级消息并发,全球平均延时小于 200 毫秒,相同区域平均延时小于 100 毫秒。

  • 支持单聊、群聊、聊天室服务;
  • 提供服务端 RESTful API 和 回调服务
  • 提供多平台 SDK,包括 Android、iOS、Web、HarmonyOS、Windows、Linux、Unity、Flutter、React Native、小程序、uni-app 和 Electron;
  • 提供 Demo 和 UIKit。

如果你的Flutter项目想运行在鸿蒙端而且你还使用了 uikit 层面的依赖库,那么还是需要做一些兼容和割舍的。「博主并不建议你这么操作,毕竟官方并不提供支持」,接下来就让博主带着这些刚需的朋友们来看一下该如何使用环信em_chat_uikit运行到鸿蒙端。

配置鸿蒙环境

参照官方说明配置环境依赖

Flutter版本调整为支持鸿蒙的版本3.22.0-ohos,目前我使用的是3.22.1-ohos-1.0.4也是可以的

environment:
  sdk: '3.4.0'
  flutter: "3.22.1-ohos-1.0.4"

依赖调整

由于涉及到ui层面的修改,因此需要本地依赖em_chat_uikit,添加im sdk插件

dependencies:
  flutter:
    sdk: flutter
    
  ...

  em_chat_uikit: 
    path: ../em_chat_uikit-2.2.0
  im_flutter_sdk_ohos:
    git:
      url: "https://github.com/easemob/im_flutter_sdk_oh.git"
      ref: 1.5.3 

替换所有需要兼容鸿蒙的第三方flutter库

dependency_overrides:
  im_flutter_sdk: 4.13.0
  im_flutter_sdk_ios: 4.13.0
  im_flutter_sdk_android: 4.13.0
  im_flutter_sdk_interface: 4.13.0

  chat_uikit_keyboard_panel:
    path: ../chat_uikit_keyboard_panel

  record:
    git:
      url: "https://gitcode.com/openharmony-sig/fluttertpc_record.git"
      path: "record"
      ref: "d40e26bd4052362d505ef8c2c600ac69aa5a967a"
  record_platform_interface:
    git:
      url: "https://gitcode.com/openharmony-sig/fluttertpc_record.git"
      path: "record_platform_interface"
      ref: "d40e26bd4052362d505ef8c2c600ac69aa5a967a"

  shared_preferences:
    git:
      url: "https://gitcode.com/openharmony-sig/flutter_packages.git" 
      path: "packages/shared_preferences/shared_preferences"

  path_provider:
    git:
      url: "https://gitcode.com/openharmony-sig/flutter_packages.git" 
      path: "packages/path_provider/path_provider"
  
  file_picker:
    git:
      url: "https://gitcode.com/openharmony-sig/fluttertpc_file_picker.git" 
      ref: "br_v8.0.7_ohos"

  image_picker:
    git:
      url: "https://gitcode.com/openharmony-sig/flutter_packages.git"
      path: "packages/image_picker/image_picker"

  audioplayers:
    git:
      url: "https://gitcode.com/openharmony-sig/flutter_audioplayers.git"
      path: "packages/audioplayers"

  video_compress:
    git:
      url: "https://gitcode.com/openharmony-sig/fluttertpc_video_compress.git"

  video_player:
    git:
      url: "https://gitcode.com/openharmony-sig/flutter_packages.git"
      path: "packages/video_player/video_player"

  flutter_localization: 
    git:
      url: "https://gitcode.com/openharmony-sig/flutter_localization.git"

  sqflite:
    git:
      url: "https://gitcode.com/OpenHarmony-SIG/flutter_sqflite.git"
      ref: 'github.com/tekartik/sqflite.git/v2.3.3+1'
      path: 'sqflite'

注意:chat_uikit_keyboard_panel插件需要额外兼容鸿蒙,目前仅在本地做修改,需要插件的可以私信博主,或者参照我之前的文章自行编写。

代码调整

检索im_flutter_sdk_oh插件中所有调用noSupport实现的方法,将em_chat_uikit中所有使用到的地方进行调整,或隐藏、或替换、或修改、或删除,例如:聊天页面获取子区方法

// 修改前
ChatThread? threadOverView = await msg.chatThread();
MessagePinInfo? pinInfo = await msg.pinInfo();
modelLists.add(
  MessageModel(
    message: msg,
    reactions: reactions,
    thread: threadOverView,
    pinInfo: pinInfo,
  ),
);
// 修改后
MessagePinInfo? pinInfo = await msg.pinInfo();
modelLists.add(
  MessageModel(
    message: msg,
    reactions: reactions,
    thread: null,
    pinInfo: pinInfo,
  ),
);

以下做修改记录(如有缺失请继续补充):

  • 子区功能调整

隐藏子区

ChatUIKitSettings.enableMessageThread = false;
  • 翻译功能调整

translateMessage以及fetchSupportedLanguages均未实现,因此翻译目标语言不支持

// 隐藏消息菜单
ChatUIKitSettings.msgItemLongPressActions
        .remove(ChatUIKitActionType.translate);
  • 举报功能调整
// 隐藏举报菜单
ChatUIKitSettings.msgItemLongPressActions
        .remove(ChatUIKitActionType.report);
  • ChatUIKitPopupMenu溢出适配
// 1.移除Container的vertical padding - 将Container改为SizedBox,去除了上下各4像素的padding,这样释放了8像素的可用空间
// 2.减小图标和文本之间的间隔 - 将SizedBox(height: 4)改为SizedBox(height: 2),进一步减少2像素
  @override
  Widget build(BuildContext context) {
    ...

    Widget content = Wrap(
      direction: Axis.horizontal,
      alignment: WrapAlignment.start,
      children: widget.actions.map((item) {
        return InkWell(
          onTap: () {
            widget.close?.call();
            item.onTap?.call();
          },
          child: SizedBox(
            width: itemWidth,
            height: itemHeight,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                if (item.icon != null)
                  SizedBox(
                    height: 28,
                    width: 28,
                    child: item.icon!,
                  ),
                const SizedBox(height: 2),
                Text(
                  item.label,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  textScaler: TextScaler.noScaling,
                  style: TextStyle(
                    color: widget.style.foregroundColor,
                    fontSize: 12,
                    fontWeight: FontWeight.w500,
                  ),
                ),
              ],
            ),
          ),
        );
      }).toList(),
    );

    ...

    return content;
  }
}

注意:在调用sdk的api实现自己的功能时,需要确认一下sdk是否真正的实现了该api而不是nosupport

权限调整

  • 添加麦克风权限

在项目目录/ohos/entry/src/main/module.json5中新增麦克风权限

{
  "module": {
    ...
    "requestPermissions": [
      {
        "name": "ohos.permission.MICROPHONE",
        "reason": "$string:reason",
        "usedScene": {
          "when": "always",
          "abilities": [
            "EntryAbility"
          ]
        }
      }
    ]
  }
}

在项目目录/ohos/AppScope/resources/base/element/string.json中添加reason描述

{
  "string": [
    ...
    {
      "name": "reason",
      "value": "录制音频,音视频通话等需要麦克风权限"
    }
  ]
}
  • 处理异常问题

目前record_bar中对于获取用户麦克风权限失败后直接抛出异常,最好是给用户一个简单的提示,因此做以下修改

  Future<void> startRecording() async {
    if (await record.hasPermission()) {
      if (await record.isRecording()) {
        return;
      }
      try {
        fileName =
            "${DateTime.now().millisecondsSinceEpoch.toString()}.$extensionName";
        record.start(recordConfig, path: "${_directory!.path}/$fileName");
        _state?.switchRecordType(RecordBarRecordType.recording);
      } catch (e) {
        throw RecordError(recordFailed, 'Failed to start recording');
      }
    } else {
      // 发送麦克风权限未授权事件
      ChatUIKit.instance
          .sendChatUIKitEvent(ChatUIKitEvent.noMicrophonePermission);
      // 显示提示对话框
      _showPermissionDeniedDialog();
    }
  }

  void _showPermissionDeniedDialog() {
    final context = _state?.context;
    if (context == null || !context.mounted) return;

    showChatUIKitDialog(
      context: context,
      title:
          ChatUIKitLocal.microphonePermissionDeniedTitle.localString(context),
      content:
          ChatUIKitLocal.microphonePermissionDeniedContent.localString(context),
      actionItems: [
        ChatUIKitDialogAction.confirm(
          label: ChatUIKitLocal.confirm.localString(context),
          onTap: () {
            Navigator.of(context).pop();
          },
        ),
      ],
    );
  }

chat_uikit_localizayions.dart中添加国际化字符串

    microphonePermissionDeniedTitle: '麦克风权限未授权',
    microphonePermissionDeniedContent: '需要麦克风权限才能录制语音消息,请前往设备设置中开启当前应用的麦克风权限。',
    
    microphonePermissionDeniedTitle: 'Microphone Permission Denied',
    microphonePermissionDeniedContent:
        'Microphone permission is required to record voice messages. Please enable it in Settings.',

其他问题

  • 如果项目中有使用到open_file插件,需要更换成open_filex,因为查看open_file源码发现它并没有对鸿蒙平台做兼容
// 嗯,可能是BUG吧
class OpenFile {
  static const MethodChannel _channel = const MethodChannel('open_file');

  OpenFile._();
  ///[filePath] On web you need to pass the file name to determine the file type
  ///[linuxDesktopName] like 'xdg'/'gnome'
  static Future<OpenResult> open(String? filePath,
      {String? type,
      String? uti,
      String linuxDesktopName = "xdg",
      bool linuxUseGio = false,
      bool linuxByProcess = false,
      Uint8List? webData}) async {
    assert(filePath != null);
    assert(linuxUseGio != false || linuxByProcess != false, "can't have both linuxUseGio and linuxByProcess");
    if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid) {
      int _result;
      var _windowsResult;
      if (Platform.isLinux) {
        var filePathLinux = Uri.file(filePath!);
        if (linuxByProcess) {
          _result =
              Process.runSync('xdg-open', [filePathLinux.toString()]).exitCode;
        } else if (linuxUseGio) {
          _result = linux.system(['gio', 'open', filePathLinux.toString()]);
        } else {
          _result = linux
              .system(['$linuxDesktopName-open', filePathLinux.toString()]);
        }
      } else if (Platform.isWindows) {
        _windowsResult = windows.shellExecute('open', filePath!);
        _result = _windowsResult <= 32 ? 1 : 0;
      } else {
        _result = -1;
      }
      return OpenResult(
          type: _result == 0 ? ResultType.done : ResultType.error,
          message: _result == 0
              ? "done"
              : _result == -1
                  ? "This operating system is not currently supported"
                  : "there are some errors when open $filePath${Platform.isWindows ? "   HINSTANCE=$_windowsResult" : ""}");
    }

    Map<String, String?> map = {
      "file_path": filePath!,
      "type": type,
      "uti": uti,
    };
    final _result = await _channel.invokeMethod('open_file', map);
    final resultMap = json.decode(_result) as Map<String, dynamic>;
    return OpenResult.fromJson(resultMap);
  }
}

在使用open_filex打开文件是需要对下载的文件路径做编码处理,因为我们的appkey是包含#字符的,直接访问会查找不到文件,这不禁让我想起了刚开始接触鸿蒙时安装ide的路径不能包含中文「嗯是国产没错了」

String filePath = "com.example.chat_uikit_harmony$path";
Uri fileUri = Uri.file(filePath);
final result = await OpenFilex.open(fileUri.toString());
debugPrint('result: ${result.toString()}');
  • Reaction添加或者移除页面未更新

在消息列表页面添加更新操作

/// 消息列表控制器
class MessagesViewController extends ChangeNotifier
    with SafeAreaDisposed, ChatObserver, MessageObserver, ThreadObserver {
  ...

  Future<void> updateReaction(
    String messageId,
    String reaction,
    bool isAdd,
  ) async {
    try {
      ...
      // 操作成功后立即刷新本地 UI
      await refreshMessageReaction(messageId);
    } catch (e) {
      chatPrint('updateReaction: $e');
    }
  }

  Future<void> refreshMessageReaction(String messageId) async {
    final index = msgModelList
        .indexWhere((element) => element.message.msgId == messageId);
    if (index != -1) {
      Message? msg = await ChatUIKit.instance.loadMessage(messageId: messageId);
      if (msg != null) {
        List<MessageReaction>? reactions = await msg.reactionList();
        msgModelList[index] = msgModelList[index].copyWith(
          message: msg,
          reactions: reactions,
        );
        lastActionType = MessageLastActionType.originalPosition;
        refresh();
      }
    }
  }
	...
}

在ReactionInfo添加onReactionChanged回调

/// Reaction页面
/// 添加onReactionChanged回调
class ChatUIKitMessageReactionInfo extends StatefulWidget {
  const ChatUIKitMessageReactionInfo(
    this.model, {
    this.onReactionChanged,
    super.key,
  });

  final MessageModel model;

  /// 当 reaction 发生变化时的回调(添加或删除)
  final VoidCallback? onReactionChanged;

  @override
  State<ChatUIKitMessageReactionInfo> createState() =>
      _ChatUIKitMessageReactionInfoState();
}

class _ChatUIKitMessageReactionInfoState
    extends State<ChatUIKitMessageReactionInfo>
    with SingleTickerProviderStateMixin, ChatUIKitThemeMixin {
  ...

  @override
  Widget themeBuilder(BuildContext context, ChatUIKitTheme theme) {
    return Column(
      children: [
        Container(
          margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
          height: 28,
          child: ListView.builder(
            scrollDirection: Axis.horizontal,
            itemCount: reactions.length,
            itemBuilder: (context, index) {
              return Padding(
                padding: const EdgeInsets.symmetric(horizontal: 4),
                child: InkWell(
                  highlightColor: Colors.transparent,
                  splashColor: Colors.transparent,
                  onTap: () {
                    tabController.animateTo(index);
                  },
                  child: ChatUIkitReactionWidget(
                    reactions[index],
                    highlightColor: Colors.transparent,
                    highlightTextColor: theme.color.isDark
                        ? theme.color.neutralColor95
                        : theme.color.neutralColor3,
                    bgColor: selectIndex == index
                        ? (theme.color.isDark
                            ? theme.color.neutralColor3
                            : theme.color.neutralColor9)
                        : Colors.transparent,
                  ),
                ),
              );
            },
          ),
        ),
        Expanded(
          child: TabBarView(
            controller: tabController,
            children: reactions
                .map(
                  (e) => ChatReactionInfoWidget(
                    msgId: messageID,
                    reaction: e,
                    onReactionDeleteTap: () {
                      onReactionDeleteTap(e);
                      // 通知消息列表页面刷新
                      widget.onReactionChanged?.call();
                    },
                  ),
                )
                .toList(),
          ),
        ),
      ],
    );
  }
  ...
}

在消息页面处理reaction变化逻辑

/// 消息页面
class MessagesView extends StatefulWidget {
  ...

  @override
  State<MessagesView> createState() => _MessagesViewState();
}

class _MessagesViewState extends State<MessagesView>
    with ChatObserver, ChatUIKitThemeMixin {
  ...
  void showReactionsInfo(BuildContext context, MessageModel model) {
    showChatUIKitBottomSheet(
      context: context,
      showCancel: false,
      body: ChatUIKitMessageReactionInfo(
        model,
        onReactionChanged: () {
          // 刷新消息列表中的 reaction 显示
          controller.refreshMessageReaction(model.message.msgId);
        },
      ),
    );
  }    
  ...
}

以上我们基本上就完成了 Flutter 向鸿蒙端的整体适配,如有其他问题也可以私信博主进行讨论。

Logo

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

更多推荐