项目的介绍地址
项目依赖介绍
鉴于目前还处于测试阶段,所以暂时只能通过 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
的核心架构设计,这样大家使用过程中发现问题能够及时修复,还能正确去使用。