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

项目的介绍地址

petrelhotupdate.github.io/

项目依赖介绍

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

json 复制代码
[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 就无法运行,怎么办呢?我是通过新写了一套方法进行支持。

dart 复制代码
export './io/io_platform.dart' if (dart.library.html) './js/platform.dart';

abstract class Platform {
  bool get isAndroid;
  bool get isIOS;
}
dart 复制代码
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;
}
dart 复制代码
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 启动时候进行注册。

调用原生播放声音

dart 复制代码
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();
}

草稿管理

dart 复制代码
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路由管理

dart 复制代码
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参数进行特殊兼容的,比如下面。

dart 复制代码
  @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 最基础的类型之外。

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);
}

实现注册代码

dart 复制代码
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);
  }
}
dart 复制代码
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);
  }
}
dart 复制代码
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引擎,给引擎注册支持的功能。

dart 复制代码
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()进行调用。

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

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

添加启动检测下载热更

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

dart 复制代码
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);
  }
}
dart 复制代码
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,
      ),
    );
  }
}

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

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

发布热更

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

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

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

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

dart 复制代码
flutter build web --release --web-renderer html

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

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

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

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

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

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

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

这样设计主要考虑两点:

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

后续

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

相关推荐
恋猫de小郭1 小时前
iOS 26 正式版即将发布,Flutter 完成全新 devicectl + lldb 的 Debug JIT 运行支持
android·前端·flutter
JulyYu5 小时前
Flutter混合栈适配安卓ActivityResult
android·flutter
海的天空16619 小时前
Flutter旧版本升级-> Android 配置、iOS配置
android·flutter·ios
小蜜蜂嗡嗡9 小时前
【flutter对屏幕底部有手势区域(如:一条横杠)导致出现重叠遮挡】
前端·javascript·flutter
ai_xiaogui20 小时前
反催收APP开发思路:用Flutter打造证据链管理工具
flutter·反催收app开发·flutter证据链管理·跨平台维权工具
木子雨廷1 天前
Flutter 开发一个plugin
前端·flutter
苦逼的搬砖工1 天前
Network Kit Lite:一个基于 SOLID 原则的 Flutter 网络框架架构设计
flutter
苦逼的搬砖工1 天前
Flutter 基础组件深度解析:从入门到精通
flutter
苦逼的搬砖工1 天前
Flutter 其他组件:让交互更丰富
flutter