Petrel(雨燕)Flutter 热更新如何在我们项目应用

882 阅读13分钟

项目的介绍地址

petrelhotupdate.github.io/

项目依赖介绍

鉴于目前还处于测试阶段,所以暂时只能通过 git 方式进行接入,我们目前项目采用是 Git Submodule 的方式进行接入到我们工程的,因为要根据需求进行经常性的变更。

[submodule "apps/flutter_metax_web"]
	path = apps/flutter_metax_web
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/flutter_metax_web.git
	branch = main
[submodule "packages/flutter_metax_base"]
	path = packages/flutter_metax_base
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/flutter_metax_base.git
	branch = main
[submodule "packages/flutter_metax_native_base"]
	path = packages/flutter_metax_native_base
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/flutter_metax_native_base.git
	branch = main
[submodule "packages/flutter_metax_pages"]
	path = packages/flutter_metax_pages
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/flutter_metax_pages.git
	branch = main
[submodule "packages/flutter_petrel"]
	path = packages/flutter_petrel
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/flutter_petrel.git
	branch = main
[submodule "packages/Petrel"]
	path = packages/Petrel
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/Petrel.git
	branch = main
[submodule "packages/petrel_code_gen"]
	path = packages/petrel_code_gen
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/petrel_code_gen.git
	branch = main
[submodule "packages/petrel_register_code_gen_annotation"]
	path = packages/petrel_register_code_gen_annotation
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/petrel_register_code_gen_annotation.git
	branch = main
[submodule "ios"]
	path = ios
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/meta_app_ios.git
	branch = dev_2.0
[submodule "android"]
	path = android
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/meta_app_android.git
	branch = dev_2.0
[submodule "metaapp_flutter"]
	path = metaapp_flutter
	url = http://xxx.xx.xx.x:xxxxx/WinnerApp/meta_app_flutter.git
	branch = dev_2.0

我来简单讲一下上面依赖这么多的项目是做什么的。

  • apps/flutter_metax_web 通过这个项目打包成 Flutter Web 的热更资源
  • packages/flutter_metax_base 存放我们提炼出来能够 Flutter Web 和 Flutter Native App 共用的基础库
  • packages/flutter_metax_native_base 存放页面提炼所需要的原生插件调用
  • packages/flutter_metax_pages 可以支持热更的页面模块
  • packages/flutter_petrel 支持 Flutter Native App 运行热更资源和 Flutter Web 进行通信
  • packages/Petrel Flutter Web 和 Flutter Native App 进行通信的基础库
  • packages/petrel_code_gen 根据提供通信模板生成通信代码
  • packages/petrel_register_code_gen_annotation 支持生成通信代码的注解
  • ios 原生 iOS 工程
  • android 原生 android 工程
  • metaapp_flutter Flutter 模块

使用melos进行管理项目

我们目前项目存在很多本地的依赖,甚至还有后续新增其他的页面。就会有很多的 Flutter 项目进行依赖,这样设置依赖就变得很麻烦,我们可以使用 melos 进行管理。

关于 melos 的安装和使用请前往melos.invertase.dev/自行查看,很简单。

目前我们项目的结构如下

  • android
  • apps
    • flutter_metax_web
  • ios
  • metaapp_flutter
  • packages
    • flutter_metax_base
    • flutter_metax_native_base
    • futter_metax_pages
    • flutter_petrel
    • flutter_web_page(自动生成,忽略文件夹)
    • petrel
    • petrel_code_gen
    • petrel_register_code_gen_annotation
  • .gitmodules
  • melos.yaml
  • create_flutter_web.sh

melos 托管的项目需要通过执行melos bootstrap进行拉取依赖,第一次如果不执行bash create_flutter_web.sh脚本会报错,因为拉取依赖找不到flutter_web_page依赖。

局部热更实现

如果全新的项目通过petrel进行实现热更是最好不过了,可以按照当前的结构进行写代码即可。甚至我想做一个工具可以创建支持热更的脚手架新项目,通过各种工具支持中间的开发和调试。

但是对于老项目,全部的进行提炼才能支持热更肯定不支持。我们可以选择新做的界面做试点来做提炼,只提炼暂时需要的放到基础库里面,甚至可以拿着最简单的界面开始都可以。

目前我们就是拿着全新实现的UGC页面进行实现,UGC 模块其实也很大,我和另外的同事也做了很久,这个时间不发包括做的时候 UI 和结构都没情况下,做好是改了又改。

我这边的任务是开发用于发布 UGC 内容的编辑器的部分,这里不但包括可以拍照,选取图片和视频,还有复杂输入框交互的部分。

为了这一点我很早准备将这部分进行提炼,比如目前pages目录存在下面的页面。

  • flutter_metax_page_cover_photo_cropper(处理封面)
  • flutter_metax_page_draft(草稿箱)
  • flutter_metax_page_product_list(商品选择列表)
  • flutter_metax_page_topic_list(话题选择列表)
  • flutter_metax_page_ugc_editor(UGC 编辑器)
  • flutter_metax_page_user_list(用户列表)

理论上上面已经整理的页面都是可以支持热更的,只要稍微的进行修改就支持的。其他历史的界面的逻辑依然存在在metaapp_flutter这个仓库里面。

不过注意在 base 新增的逻辑和方法可能和之前老工程的冲突,这个通过导入或者前缀处理一下即可,并不是大问题。

条件导入

对于大多情况都能支持 Flutter Web 和 Flutter Native App 的代码完全没有问题,但是还有一些比如dart.io这个库就只能在 Flutter Native App 编译。

对于我们平时最常见的Platform.isAndroid都来自这个库,但是我们这个代码在 Flutter Web 就无法运行,怎么办呢?我是通过新写了一套方法进行支持。

export './io/io_platform.dart' if (dart.library.html) './js/platform.dart';

abstract class Platform {
  bool get isAndroid;
  bool get isIOS;
}
import 'package:flutter_metax_base/platforms/platform.dart';
import 'dart:io' as io;

Platform getPlatform() {
  return IoPlatform();
}

class IoPlatform extends Platform {
  @override
  bool get isAndroid => io.Platform.isAndroid;
  @override
  bool get isIOS => io.Platform.isIOS;
}
import 'package:flutter_metax_base/platforms/platform.dart';

Platform getPlatform() => JsPlatform();

class JsPlatform extends Platform {
  @override
  bool get isAndroid => false;
  @override
  bool get isIOS => false;
}

这样在其他地方直接调用getPlatform就可以了,也不会在 Flutter Web 报错。

注册功能

Flutter Web Page 的功能并不能完全闭环,总有一些不支持的地方,我们就可以将不支持的地方在 Flutter Native Page 进行注册供 Flutter Web 调用即可。

功能的注册分为两部分,第一部分是不涉及业务部分,只负责调用方法传递 JSON 和返回 JSON 的接口。第二部分是需要对应逻辑实现的部分,放在 Flutter Native App 部分在 app 启动时候进行注册。

调用原生播放声音

import 'package:flutter_metax_base/commons/define.dart';
import 'package:petrel/petrel.dart';
import 'package:petrel_register_code_gen_annotation/petrel_register_code_gen_annotation.dart';

part 'audio_manager_register.r.dart';

@PetrelRegisterClass()
abstract class _AudioManagerRegister extends PetrelRegister {
  @override
  String get className => 'audioManager';

  @override
  String get libraryName => metaxLibraryName;

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<void>> $playDialogOrToast();

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<void>> $playEnter();

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<void>> $playBack();
}

草稿管理

import 'package:flutter_metax_base/flutter_metax_base.dart';
import 'package:petrel/petrel.dart';
import 'package:petrel_register_code_gen_annotation/petrel_register_code_gen_annotation.dart';
part 'draft_register.r.dart';

@PetrelRegisterClass()
abstract class _DraftRegister extends PetrelRegister {
  @override
  String get libraryName => metaxLibraryName;

  @override
  String get className => 'Draft';

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<bool>> $saveDraft(
      @PetrelRegisterMethodParam() DraftModel model);

  @PetrelRegisterMethod(
      customConverter:
          'NativeChannelObjectList.fromJson(e, DraftModel.fromJson)')
  Future<NativeChannelObjectList<DraftModel>> $getAllDrafts();

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<bool>> $deleteDraft(String draftId);

  @PetrelRegisterMethod()
  Future<DraftModel?> $getDraft(String draftId);

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<void>> $openBox();

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<void>> $closeBox();

  /// 保存封面图片到本地
  /// [draftId] 草稿ID
  /// [imageBase64] 图片base64
  /// [filePath] 图片路径
  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<bool>> $saveCoverImage(
    String draftId,
    String imageBase64,
    String filePath,
  );

  /// 根据草稿ID获取保存封面的路径地址
  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<String>> $getCoverImagePath(String draftId);

  /// 获取草稿封面图片
  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<String?>> $getCoverImage(String draftId);
}

Getx路由管理

import 'package:flutter_metax_base/flutter_metax_base.dart';
import 'package:petrel/petrel.dart';
import 'package:petrel_register_code_gen_annotation/petrel_register_code_gen_annotation.dart';
part 'get_register.r.dart';

@PetrelRegisterClass()
abstract class _GetRegister extends PetrelRegister {
  @override
  String get className => 'Get';

  @override
  String get libraryName => metaxLibraryName;

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<Map<String, dynamic>?>> $toNamed(
    String page, {
    Map<String, dynamic>? arguments,
    int? id,
    bool? preventDuplicates,
    Map<String, String>? parameters,
  });

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<void>> $back(
      {Map<String, dynamic>? result});

  @PetrelRegisterMethod()
  Future<DefaultNativeChannelObject<Map>> $arguments();
}

这些是我们需要新功能是否需要写的方法,这个地方有几点需要注意。

  • 定义的类型必须_开头继承于PetrelRegister
  • 需要添加@PetrelRegisterClass()注解需要生成的类名
  • @PetrelRegisterMethod()声明需要生成的方法名称
  • 返回的类型必须是DefaultNativeChannelObject 类用来保证传输的内容可以被 JSON 进行解析

这里面还有@PetrelRegisterMethod需要设置customConverter参数进行特殊兼容的,比如下面。

  @PetrelRegisterMethod(
      customConverter:
          'NativeChannelObjectList<AssetEntity>.fromJson(e, (e) => AssetEntity.fromJson(e))')
  Future<NativeChannelObjectList<AssetEntity>> $pickAssets({
    List<String> selectedAssets = const [],

    /// 是否允许选择视频 默认允许
    bool allowVideo = true,
  });

自动生成的解析代码会报错,我们就需要通过这个customConverter进行特殊的设置,将正确的解析的代码设置替换默认生成给的代码。

我们还看到了NativeChannelObjectList这和类,这个是为了可以返回数组类型的内容。

除了上面的注解,还有一个重要的注解就是PetrelRegisterMethodParam,这个可以让我们支持更复杂的类型,除了 Dart 最基础的类型之外。

  @PetrelRegisterMethod()
  Future<PermissionStatus> $status(
      @PetrelRegisterMethodParam() Permission permission);

class Permission extends DefaultNativeChannelObject<int> {
  Permission(super.value);
  factory Permission._(int value) => Permission(value);
  factory Permission.fromJson(Map<String, dynamic> json) =>
      Permission._(json['value']);
  static Permission photos = Permission._(9);
  static Permission storage = Permission._(15);
}

实现注册代码

import 'package:flutter_metax_base/flutter_metax_base.dart';
import 'package:petrel/petrel.dart';

class AudioManagerRegister extends $AudioManagerRegister {
  @override
  Future<DefaultNativeChannelObject<void>> $playBack() async {
    global.audioManager.playBack();
    return DefaultNativeChannelObject(null);
  }

  @override
  Future<DefaultNativeChannelObject<void>> $playDialogOrToast() async {
    global.audioManager.playDialogOrToast();
    return DefaultNativeChannelObject(null);
  }

  @override
  Future<DefaultNativeChannelObject<void>> $playEnter() async {
    global.audioManager.playEnter();
    return DefaultNativeChannelObject(null);
  }
}
import 'dart:io';
import 'package:flutter_metax_base/flutter_metax_base.dart';
import 'package:hive/hive.dart';
import 'package:meta_winner_app/common/functions.dart';
import 'package:meta_winner_app/common/getx_servers/query_user_info_manager.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:convert';
import 'package:path/path.dart';
import 'package:petrel/petrel.dart';

class DraftRegister extends $DraftRegister {
  static String get _boxName {
    int? userId = getFind<QueryUserInfoManager>()?.activeUser?.uid;
    return 'drafts_${userId ?? 0}';
  }

  static Box<String>? _draftsBox;

  static Future<Box<String>> get draftsBox async {
    if (_draftsBox == null) {
      await DraftRegister().$openBox();
    }
    return _draftsBox!;
  }

  /// 初始化Hive
  @override
  Future<DefaultNativeChannelObject<void>> $openBox() async {
    final directory = await getApplicationDocumentsDirectory()
        .then((e) => join(e.path, 'hive'));
    Hive.init(directory);
    _draftsBox = await Hive.openBox<String>(_boxName);
    return DefaultNativeChannelObject(null);
  }

  /// 保存草稿
  /// [content] 草稿内容
  @override
  Future<DefaultNativeChannelObject<bool>> $saveDraft(DraftModel model) async {
    final jsonString = jsonEncode(model.toJson());
    await draftsBox.then((e) => e.put(model.id, jsonString));
    return DefaultNativeChannelObject(true);
  }

  /// 读取草稿
  /// 返回草稿内容,如果不存在则返回null
  @override
  Future<DraftModel?> $getDraft(String key) async {
    final jsonString = await draftsBox.then((e) => e.get(key));
    if (jsonString == null) {
      return null;
    }
    return DraftModel.fromJson(jsonDecode(jsonString));
  }

  /// 删除草稿
  /// [key] 草稿的唯一标识
  @override
  Future<DefaultNativeChannelObject<bool>> $deleteDraft(String key) async {
    await draftsBox.then((e) => e.delete(key));
    return DefaultNativeChannelObject(true);
  }

  /// 获取所有草稿
  /// 返回所有草稿的Map
  @override
  Future<NativeChannelObjectList<DraftModel>> $getAllDrafts() async {
    final result = <DraftModel>[];
    for (final key in await draftsBox.then((e) => e.keys)) {
      if (key == null) {
        continue;
      }
      final draft = await $getDraft(key.toString());
      if (draft != null) {
        result.add(draft);
      }
    }
    return NativeChannelObjectList(result);
  }

  /// 清空所有草稿
  Future<void> clearAllDrafts() async {
    await draftsBox.then((e) => e.clear());
  }

  @override
  Future<DefaultNativeChannelObject<String>> $getCoverImagePath(
      String draftId) async {
    final directory = await getApplicationDocumentsDirectory()
        .then((e) => join(e.path, 'drafts'));
    return DefaultNativeChannelObject(join(directory, '$draftId.jpg'));
  }

  @override
  Future<DefaultNativeChannelObject<void>> $closeBox() async {
    await _draftsBox?.close();
    _draftsBox = null;
    return DefaultNativeChannelObject(null);
  }

  @override
  Future<DefaultNativeChannelObject<String?>> $getCoverImage(
      String draftId) async {
    final filePath = await $getCoverImagePath(draftId).then((e) => e.value);
    final file = File(filePath);
    if (!await file.exists()) {
      return DefaultNativeChannelObject(null);
    }
    final bytes = await file.readAsBytes();
    return DefaultNativeChannelObject(base64Encode(bytes));
  }

  @override
  Future<DefaultNativeChannelObject<bool>> $saveCoverImage(
      String draftId, String imageBase64, String filePath) async {
    final file = File(filePath);
    if (!await file.exists()) {
      await file.create(recursive: true);
    }
    final bytes = base64Decode(imageBase64);
    await file.writeAsBytes(bytes);
    return DefaultNativeChannelObject(true);
  }
}

import 'package:flutter_metax_base/packages/get/get_register.dart';
import 'package:get/get.dart';
import 'package:meta_winner_app/common/functions.dart';
import 'package:petrel/petrel.dart';

class GetRegister extends $GetRegister {
  @override
  Future<DefaultNativeChannelObject<void>> $back(
      {Map<String, dynamic>? result}) async {
    Get.back(result: result);
    return DefaultNativeChannelObject(null);
  }

  @override
  Future<DefaultNativeChannelObject<Map<String, dynamic>?>> $toNamed(
      String page,
      {Map<String, dynamic>? arguments,
      int? id,
      bool? preventDuplicates,
      Map<String, String>? parameters}) async {
    /// 是为了能够修复 GetMiddule 暂时不支持合并之前路由的问题
    final value = await getToName(
      page,
      arguments: arguments,
      id: id,
      preventDuplicates: false,
      parameters: parameters,
    );
    return DefaultNativeChannelObject(value);
  }

  @override
  Future<DefaultNativeChannelObject<Map>> $arguments() async {
    return DefaultNativeChannelObject(Get.arguments);
  }
}

添加注册监听

我们需要在启动 app 的时候初始化Petrel引擎,给引擎注册支持的功能。

void initPetrel() {
  final registerCenter = nativeChannelEngine.registerCenter;
  registerCenter.addRegister(DraftRegister());
  registerCenter.addRegister(AssetPickerRegister());
  registerCenter.addRegister(WeChatCameraPickerRegister());
  registerCenter.addRegister(GetRegister());
  registerCenter.addRegister(UgcUploadRegister());
  registerCenter.addRegister(GetThumbnailVideoRegister());
  registerCenter.addRegister(GlobalFunctionRegister());
  registerCenter.addRegister(AudioManagerRegister());
  registerCenter.addRegister(SensorAnalyticsRegister());
  nativeChannelEngine.initEngine();
}

目前我们 app 暂时注册了几个,分别有草稿管理,相册读取,拍照,Get 路由管理,获取视频缩略图,公共方法调用,音频播放,神策埋点等等。

怎么调用

我们日常开发中如何使用呢?我们平时将之前的页面提炼成 Package,那么很多依赖都没有了,对应的方法都没有了。对于可以提炼到 Base 层被编译就就提炼,不可以就上面一样写通道方法通过我们封装方法进行调用。

比如我们将 Get 库进行封装之后,我们在 Package 调用Get.toNamed就需要调用这样的GetRegisterManager().toNamed()进行调用。

目前的自动生成代码规则是基于我们定义名称会生成下面的类

/// 自己创建类型 _GetRegister
/// 自动生成的基类 $GetRegister
/// 自动生成的调用管理类 GetRegisterManager
/// 需要开发新建一个类继承于$GetRegister实现对应的方法

添加启动检测下载热更

目前我测试服务器是放在 Appwrite 上面,我推荐放在这上面,简单。对于想放在自己服务器,自己自定义下载的可能要自己写一下自动化工具了。

import 'dart:io';

import 'package:appwrite/appwrite.dart' as appwrite;
import 'package:appwrite/models.dart' hide File;
import 'package:flutter/foundation.dart';
import 'package:flutter_archive/flutter_archive.dart';
import 'package:flutter_metax_base/flutter_metax_base.dart' hide global;
import 'package:get/get.dart';
import 'package:meta_winner_app/common/dart_environment.dart';
import 'package:meta_winner_app/common/defines.dart';
import 'package:path/path.dart';
import 'package:petrel/petrel.dart';
import 'package:flutter/services.dart'; // Add this import

class FlutterWebVersionManager extends GetxService {
  final String appwriteProjectId = 'xxxxxxxxxxxxxxxxxxxx';
  final String appwriteDatabaseId = 'xxxxxxxxxxxxxxxxxxxx';
  final String appwriteVersionCollectionId = 'xxxxxxxxxxxxxxxxxxxx';
  final String appwriteResouceCollectionId = 'xxxxxxxxxxxxxxxxxxxx';
  final String appwritePackageCollectionId = 'xxxxxxxxxxxxxxxxxxxx';
  final String appwriteBucketId = 'xxxxxxxxxxxxxxxxxxxx';

  final FlutterWebCachePathUtil _cachePathUtil = FlutterWebCachePathUtil();

  late appwrite.Client _client;

  /// 当前支持热更的路由
  List<String> supportedRoutes = [];

  /// 已经使用的端口列表
  final List<int> usedPorts = [];

  @override
  onInit() {
    super.onInit();
    _client = appwrite.Client()
      ..setProject(appwriteProjectId)
      ..setEndpoint('https://appwrite.winnermedical.com/v1');
  }

  /// 请求并下载热更资源到本地
  Future<void> checkAndDownloadFlutterWebResource({
    required String appVersion,
    required int buildNumber,
    required bool isProduction,
    required String phoneNumber,
    required List<String> routeNames,
    required Map branchMap,
  }) async {
    /// 查询所有符合的热更版本
    for (var routeName in routeNames) {
      await _downloadFlutterWebResource(
        appVersion: appVersion,
        buildNumber: buildNumber,
        routeName: routeName,
        isProduction: isProduction,
        phoneNumber: phoneNumber,
        branch: branchMap[routeName],
      ).catchError((e, s) {
        logger.e('Download flutter web resource failed: $e', stackTrace: s);
      });
    }

    final allRouteNames = await _cachePathUtil
        .getPageResourceDirectory()
        .then((e) async {
          if (await Directory(e).exists()) {
            return Directory(e).list().toList();
          }
          return [];
        })
        .then((e) => e.whereType<Directory>())
        .then((e) => e.map((e) => e.path).toList());
    for (var routeName in allRouteNames) {
      if (!supportedRoutes.contains(routeName)) {
        final routeDir = await _cachePathUtil.getRouteResourceDirectory(
            routeName: routeName);
        if (await Directory(routeDir).exists()) {
          await Directory(routeDir).delete(recursive: true);
        }
      }
    }
  }

  /// 下载对应路由的热更资源到本地
  Future<void> _downloadFlutterWebResource({
    required String appVersion,
    required int buildNumber,
    required bool isProduction,
    required String phoneNumber,
    required String routeName,
    String? branch,
  }) async {
    /// 第一步查询当前路由的最新可以热更的版本
    final version = await getMatchingHotfixVersions(
      appVersion: appVersion,
      buildNumber: buildNumber,
      isProduction: isProduction,
      phoneNumber: phoneNumber,
      routeName: routeName,
      branch: branch,
    );
    if (version == null) {
      logger.i('No matching hotfix version found for $routeName');
      return;
    }
    logger
        .i('Matching hotfix version found for $routeName: ${version.toMap()}');

    final routeDir =
        await _cachePathUtil.getRouteResourceDirectory(routeName: routeName);

    /// 当前版本的资源列表
    final resources = JSON(version.data)['resources'].listValue;
    if (await Directory(routeDir).exists()) {
      await Directory(routeDir).delete(recursive: true);
    }
    for (var resourceId in resources) {
      /// 查询当前资源的信息
      final resourceInfo = await getResourceInfo(resourceId: resourceId);
      final md5 = JSON(resourceInfo.data)['md5'].stringValue;
      final fileId = JSON(resourceInfo.data)['fileId'].stringValue;
      final size = JSON(resourceInfo.data)['size'].intValue;
      final path = JSON(resourceInfo.data)['path'].stringValue;

      final downloadDir = await _cachePathUtil.getDownloadResourceDirectory();

      final md5ZipPath = join(downloadDir, '$md5.zip');
      if (await File(md5ZipPath).exists()) {
        logger.i('Resource $md5 has already been downloaded');
      } else {
        final sorage = appwrite.Storage(_client);
        final file = await sorage.getFileDownload(
          bucketId: appwriteBucketId,
          fileId: fileId,
        );
        if (!await Directory(downloadDir).exists()) {
          await Directory(downloadDir).create(recursive: true);
        }
        await File(md5ZipPath).writeAsBytes(file);
        logger.i('Resource $md5 downloaded to $md5ZipPath');
      }

      // 解压文件
      final rootIsolateToken =
          ServicesBinding.rootIsolateToken; // Get root isolate token
      final md5UnzipDir = await _cachePathUtil.getUnzipDirectory(md5);
      final md5UnzipFile = File(join(md5UnzipDir, path));
      final copyFile = File(join(routeDir, path));
      if (!await md5UnzipFile.exists()) {
        await compute<Map, void>(
          _unzipFlutterWebResource,
          {
            'zipFile': File(md5ZipPath),
            'destinationDir': Directory(md5UnzipDir),
            'rootIsolateToken': rootIsolateToken, // Pass token to isolate
          },
          debugLabel: '解压文件',
        );
        logger.i('Resource $md5 extracted successfully');
      } else {
        logger.i('Resource $md5 has already been extracted');
      }
      if (!await copyFile.parent.exists()) {
        await copyFile.parent.create(recursive: true);
      }
      await md5UnzipFile.copy(copyFile.path);
      if (await copyFile.length() != size) {
        throw '[${copyFile.path}] size mismatch';
      }
    }
    logger.i('🟢Route $routeName supported');
    supportedRoutes.add(routeName);
  }

  static Future<void> _unzipFlutterWebResource(Map args) async {
    // Initialize isolate communication channel
    final RootIsolateToken? rootIsolateToken = args['rootIsolateToken'];
    if (rootIsolateToken != null) {
      BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
    }

    File zipFile = args['zipFile'];
    Directory destinationDir = args['destinationDir'];

    return ZipFile.extractToDirectory(
      zipFile: zipFile,
      destinationDir: destinationDir,
    );
  }

  /// 获取当前路由可热更的版本
  Future<Document?> getMatchingHotfixVersions({
    required String appVersion,
    required int buildNumber,
    required bool isProduction,
    required String phoneNumber,
    required String routeName,
    String? branch,
  }) async {
    logger.d(
      'Get matching hotfix versions: $appVersion, $buildNumber, $isProduction, $phoneNumber, $routeName',
    );
    final databases = appwrite.Databases(_client);
    final branchMap = DartEnvironment.branchMap;
    final packageBranchText = branchMap['package_branch_text'];
    final packageVersionText = branchMap['package_version_text'];
    if (packageBranchText == null || packageVersionText == null) {
      logger.w('package_branch_text or package_version_text is null');
      return null;
    }

    // 构建查询条件
    final queries = [
      // 基础条件:必须开启
      appwrite.Query.equal('enable', true),
      // 生产环境匹配
      appwrite.Query.equal('is_store', isProduction),
      appwrite.Query.equal('routeName', routeName),
      appwrite.Query.equal('package_branch_text', packageBranchText),
      appwrite.Query.equal('package_version_text', packageVersionText),
      // appwrite.Query.or([
      //   appwrite.Query.equal('allow_phones', []),
      //   appwrite.Query.contains('allow_phones', phoneNumber),
      // ]),
      appwrite.Query.orderDesc('version'),
    ];

    logger.d('query: $queries');

    // 执行查询(请替换为您的实际数据库ID和集合ID)
    final response = await databases
        .listDocuments(
            databaseId: appwriteDatabaseId,
            collectionId: appwriteVersionCollectionId,
            queries: queries)
        .then<Document?>((e) async {
      // logger.d(
      //     'Get matching hotfix versions: ${e.documents.map((e) => e.toMap())}');
      List<Document> documents = [];
      for (var i = 0; i < e.documents.length; i++) {
        final document = e.documents[i];
        if (global.isFlutterWebIgnoreVersion.value) {
          documents.add(document);
          continue;
        }
        final allowPhones = JSON(document.data)['allow_phones']
            .listValue
            .map((e) => e.toString())
            .toList();
        if (allowPhones.isNotEmpty && !allowPhones.contains(phoneNumber)) {
          continue;
        }
        final packageIds = JSON(document.data)['package_ids']
            .listValue
            .map((e) => e.toString())
            .toList();
        List<int> compareResult = [];
        for (var i = 0; i < packageIds.length; i++) {
          final packageDocument = await databases
              .getDocument(
            databaseId: appwriteDatabaseId,
            collectionId: appwritePackageCollectionId,
            documentId: packageIds[i],
          )
              .catchError((e) {
            logger.e(e);
            throw e;
          });
          final remotePackageName =
              JSON(packageDocument.data)['name'].stringValue;
          final remoteBranch = JSON(packageDocument.data)['branch'].stringValue;
          final remoteVersion = JSON(packageDocument.data)['version'].intValue;
          final gitVersion = JSON(packageDocument.data)['git_version'].intValue;

          final localPackageMap = branchMap[remotePackageName];
          final localPackageName = JSON(localPackageMap)['name'].stringValue;
          final localBranch = JSON(localPackageMap)['branch'].stringValue;
          final localVersion = JSON(localPackageMap)['version'].intValue;
          final localGitVersion = JSON(localPackageMap)['git_version'].intValue;
          if (remotePackageName != localPackageName ||
              remoteBranch != localBranch ||
              remoteVersion != localVersion) {
            break;
          }
          compareResult.add(localGitVersion.compareTo(gitVersion));
          await Future.delayed(const Duration(milliseconds: 100));
        }
        if (compareResult.length != packageIds.length) {
          break;
        }
        if (compareResult.any((e) => e < 0)) {
          /// 只要一个是就版本提交就允许更新
          documents.add(document);
        }
      }
      return documents.firstOrNull;
    }).catchError((e, s) {
      logger.e(e, stackTrace: s);
      return null;
    });

    return response;
  }

  /// 获取当前资源的信息
  Future<Document> getResourceInfo({required String resourceId}) async {
    final databases = appwrite.Databases(_client);
    final response = await databases.getDocument(
        databaseId: appwriteDatabaseId,
        collectionId: appwriteResouceCollectionId,
        documentId: resourceId);
    return response;
  }

  /// 获取可用的端口
  int getAvailablePort() {
    int port = 0;
    for (var i = 10000; i < 65535; i++) {
      if (!usedPorts.contains(i)) {
        port = i;
      }
    }
    if (port == 0) {
      throw Exception('No available port');
    }
    usedPorts.add(port);
    return port;
  }

  /// 释放端口
  void releasePort(int port) {
    usedPorts.remove(port);
  }

  /// 释放所有端口
  void releaseAllPorts() {
    usedPorts.forEach(releasePort);
  }
}

import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';

class FlutterWebCachePathUtil {
  /// Flutter Web 缓存的总目录
  Future<String> getFlutterWebCacheDirectory() {
    return getApplicationDocumentsDirectory().then(
      (value) => join(
        value.path,
        'flutter_web_cache',
      ),
    );
  }

  /// 获取下载的资源包保存的目录
  Future<String> getDownloadResourceDirectory() {
    return getFlutterWebCacheDirectory().then(
      (value) => join(
        value,
        'download',
      ),
    );
  }

  /// 获取路由资源保存的目录
  Future<String> getPageResourceDirectory() {
    return getFlutterWebCacheDirectory().then(
      (value) => join(
        value,
        'pages',
      ),
    );
  }

  /// 获取对应路由的资源保存目录
  Future<String> getRouteResourceDirectory({required String routeName}) {
    return getPageResourceDirectory().then(
      (value) => join(
        value,
        routeName.replaceAll('/', ''),
      ),
    );
  }

  /// 解压文件保存的目录
  Future<String> getUnzipDirectory(String md5) {
    return getFlutterWebCacheDirectory().then(
      (value) => join(
        value,
        'unzip',
        md5,
      ),
    );
  }
}

目前我们项目是在进入首页时候启动检测和下载热更资源的,因为需要过滤支持特定手机号的功能,所以登录之后页面就是首页,可以保障在首页可以拿到手机号。

目前根据路由名称 环境还有综合的版本信息请求和下载热更,如果中间请求失败或者下载失败都会默认认为不支持热更,走之前的页面逻辑。

发布热更

目前发布热更我是暂时写到了我给公司目前写的自动化工具里面,因为里面很多逻辑和配置都有,写起来很快。

image.png

目前发布的流程已经简化到必要的几个字段,我分别介绍一下什么意思。

  • 是否开启热更(如果选 false 只暂时打包不开放热更)
  • 路由名称 (选择发布发更的页面路由工程)
  • 允许的手机号列表(设置手机号的热更只允许指定手机号的账户才支持下载 为空则全量)
  • 是否为商店版本 (是否发布应用市场环境的热更)

执行完毕会根据发布热更的路由工程生成一个flutter_web_page的库之后更新到flutter_metax_web工程里面,执行打包。

flutter build web --release --web-renderer html

目前只测试打包 html 的经过测试,其他渲染方式还没试过。

image-20250826110239580.png

image-20250826110259851.png

对于变更的文件是上图紫色的部分需要上传,上图只显示更新了三个文件。绿色的代表这个文件已经存在,不需要额外的上传。对于需不需要上传是根据计算文件的md5 看看资源库是否存在一样的,存在代表不需要额外上传,不存在代表需要上传。

怎么判断当前app是否需要热更?

最开始设计的时候,只要服务器存在符合条件的热更资源就热更。但是我想了如果当前已经发布了最新的版本到应用商店,继续走热更不是逻辑倒退回去了吗?这个时候应该走原生 Flutter 逻辑,这样性能也比热更的快。

那怎么修改才能知道需不需要支持呢,就新增了两个字段package_branch_text和package_version_text。

package_branch_text包含了所有依赖库发布时候的版本信息,package_version_text包含了所有依赖库支持热更的大版本。

对于发布的热更如果和当初的版本号不匹配就无法更新热更,如果安装包里面的热更的版本和发布的版本不能一样也不支持热更。

这样设计主要考虑两点:

  • 针对于不同分支逻辑不同,不可能让走一样的热更资源
  • 针对于后续新增通道方法肯定不能走一样的热更,所以需要用户需要更新通道对应的版本

后续

关于功能配置的基本讲述的差不多了,后面我准备下一个关于Petrel的核心架构设计,这样大家使用过程中发现问题能够及时修复,还能正确去使用。