项目的介绍地址
项目依赖介绍
鉴于目前还处于测试阶段,所以暂时只能通过 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,
),
);
}
}
目前我们项目是在进入首页时候启动检测和下载热更资源的,因为需要过滤支持特定手机号的功能,所以登录之后页面就是首页,可以保障在首页可以拿到手机号。
目前根据路由名称 环境还有综合的版本信息请求和下载热更,如果中间请求失败或者下载失败都会默认认为不支持热更,走之前的页面逻辑。
发布热更
目前发布热更我是暂时写到了我给公司目前写的自动化工具里面,因为里面很多逻辑和配置都有,写起来很快。
目前发布的流程已经简化到必要的几个字段,我分别介绍一下什么意思。
- 是否开启热更(如果选 false 只暂时打包不开放热更)
- 路由名称 (选择发布发更的页面路由工程)
- 允许的手机号列表(设置手机号的热更只允许指定手机号的账户才支持下载 为空则全量)
- 是否为商店版本 (是否发布应用市场环境的热更)
执行完毕会根据发布热更的路由工程生成一个flutter_web_page的库之后更新到flutter_metax_web工程里面,执行打包。
flutter build web --release --web-renderer html
目前只测试打包 html 的经过测试,其他渲染方式还没试过。
对于变更的文件是上图紫色的部分需要上传,上图只显示更新了三个文件。绿色的代表这个文件已经存在,不需要额外的上传。对于需不需要上传是根据计算文件的md5 看看资源库是否存在一样的,存在代表不需要额外上传,不存在代表需要上传。
怎么判断当前app是否需要热更?
最开始设计的时候,只要服务器存在符合条件的热更资源就热更。但是我想了如果当前已经发布了最新的版本到应用商店,继续走热更不是逻辑倒退回去了吗?这个时候应该走原生 Flutter 逻辑,这样性能也比热更的快。
那怎么修改才能知道需不需要支持呢,就新增了两个字段package_branch_text和package_version_text。
package_branch_text包含了所有依赖库发布时候的版本信息,package_version_text包含了所有依赖库支持热更的大版本。
对于发布的热更如果和当初的版本号不匹配就无法更新热更,如果安装包里面的热更的版本和发布的版本不能一样也不支持热更。
这样设计主要考虑两点:
- 针对于不同分支逻辑不同,不可能让走一样的热更资源
- 针对于后续新增通道方法肯定不能走一样的热更,所以需要用户需要更新通道对应的版本
后续
关于功能配置的基本讲述的差不多了,后面我准备下一个关于Petrel的核心架构设计,这样大家使用过程中发现问题能够及时修复,还能正确去使用。