光阴似箭,新的一年又开始啦 ~ 大家元旦快乐
最近我用 Rust 设计并搭建了一套后端服务,后续 FlutterUnit 将会有更多网络请求的数据啦。目前来小试牛刀的就是对于一个野生 App 而言至关重要的 应用内更新。现在正式宣布,FlutterUnit 3.1.0 版本之后,windows 和 macos 也支持应用内更新啦 ~
而且它们的发布版也已经打包成了 .exe 和 .dmg 安装程序
这次更新 FlutterUnit 桌面端支持了全局应用快捷键搜索:
1. 桌面端更新交互
FlutterUnit 的应用更新依然是不干扰用户,不会主动弹出更新的对话框来打断用户,而是以更新的红色圆点示意,引导有更新需求的用户主动操作。应用更新相关的信息采用全局的状态管理,当检测到版本有更新时:
- 桌面端左下角设置按钮的右上角会展示圆点;
- 设置页的版本信息会展示
新版本
的字样,引导用户下载;
进入版本信息,条目会展示版本升级情况,点击时弹出更新对话框,并附加更新内容。点击 立即升级
就可以下载最新的安装包:
之后会展示下载进度,也可以点击后台执行,关闭对话框:
在后台下载过程中,界面中也可以看到下载的进度信息,应用更新信息的全局状态管理,在其中起到了关键的作用:
注: Macos版本暂时没有签名,应用内下载无法安装,目前点击升级时会跳到浏览器下载。后续签名后会支持应用内更新。
2. 后端应用更新接口
真正接触 Rust 已经半年多了,我将之前 Springboot 为 FlutterUnit 写的接口逐渐迁移到了 Rust 版。目前 rust 的服务端 基于 axum 框架实现。
目前应用内更新接口分为两张表,其一是 t_app
记录具有唯一性的应用实体,不止是 FlutterUnit 后续我的其他应用更新也会使用该接口。所以用一张表维护是有必要的:
CREATE TABLE t_app
(
app_id BIGINT(20) NOT NULL COMMENT '主键ID',
app_name VARCHAR(20) NOT NULL COMMENT 'app 名称',
create_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (app_id),
UNIQUE KEY(app_name)
);
1 个应用实体对应 n 个版本记录,对于全平台的应用程序而言,版本记录的唯一性应该由 appId
、版本号、操作系统决定。设计如下的 t_app_version
表示一个应用程序某一平台某一版本的信息:
CREATE TABLE t_app_version
(
version_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
app_id BIGINT(20) NOT NULL COMMENT 'app的ID',
os VARCHAR(20) NOT NULL COMMENT '平台',
version VARCHAR(24) NOT NULL COMMENT '版本',
url VARCHAR(128) NOT NULL COMMENT '下载地址',
description TEXT NOT NULL COMMENT '版本描述',
size INT NOT NULL COMMENT '安装包大小',
sha256 CHAR(64) NOT NULL COMMENT 'sha256',
create_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (version_id),
UNIQUE KEY(app_id,os,version)
);
然后,实现增删改查的接口就行了,对与应用程序而言,最重要的是根据操作系统和 appid 获取版本最新信息的查询接口:
下面是通过 PostMan 客户端的调用方式和结果:
对于全平台的野生应用打包发布而言,最好有一套自动构建发布的脚本。不然一个个手动打包,然后配置更新信息,是一件不小的工作量。目前我已经完成了 FlutterUnit 的打包脚本,后续版本更新会非常方便。
3. FlutterUnit 更新相关的代码
交互和后端接口说完了,下面简单说一下前端的代码。FlutterUnit 应用内更新的核心逻辑在 app_update 模块。目前这个模块和主体项目还比较耦合,后面有时间会拆分一下,让它可以独立运转。甚至可以纳入 fx 框架中:
状态 State
FlutterUnit 中将应用更新视为全局的状态,由 UpgradeBloc 进行维护。在状态数据层面,通过 sealed
分化出四类 UpdateState,分别表示需要更新、更新异常、检测中、不需要更新:
四者持有各自状态需要的数据信息,其中最复杂的是 ShouldUpdateState
, 它记录了更新所需的信息内容,包括旧版本、最新的 AppInfo、以及下载进度:
class ShouldUpdateState extends UpdateState {
final String oldVersion;
final double progress;
final AppInfo info;
const ShouldUpdateState({
required this.oldVersion,
required this.info,
this.progress = 0,
});
AppInfo 类承载着最新的 app 信息,也就是接口返回数据的封装体:
class AppInfo {
final String version;
final String url;
final int size;
final String? description;
final String? sha256;
const AppInfo({
required this.version,
required this.url,
required this.size,
required this.description,
required this.sha256,
});
行为 Event
在行为事件层面,通过 sealed
分化出三类 UpdateEvent:
- CheckUpdate 是检测更新的行为,调用它时会触发检测更新,进行状态变化。
- DownloadEvent 是更新行为,调用它会触发下载更新逻辑。
业务逻辑 UpgradeBloc
UpgradeBloc 负责监听 UpdateEvent,处理业务逻辑,产出对应的 UpdateState。如下所示 UpgradeApi#fetch
方法获取最新的版本信息,根据结果产出不同的状态:
class UpgradeBloc extends Bloc<UpdateEvent, UpdateState> {
final UpgradeApi api;
UpgradeBloc({required this.api}) : super(const NoUpdateState()) {
on<CheckUpdate>(_onCheckUpdate);
on<DownloadEvent>(_onDownloadEvent);
}
void _onCheckUpdate(CheckUpdate event, Emitter<UpdateState> emit) async {
emit(const CheckLoadingState());
ApiRet<AppInfo> ret = await api.fetch(event.appId);
if (ret.failed) {
emit(UpdateErrorState(error: ret.msg));
return;
}
AppInfo result = ret.data;
PackageInfo packageInfo = await PackageInfo.fromPlatform();
if (result.shouldUpgrade(packageInfo.version)) {
emit(ShouldUpdateState(oldVersion: packageInfo.version, info: result));
} else {
int time = DateTime.now().millisecondsSinceEpoch;
emit(NoUpdateState(isChecked: true, checkTime: time));
}
}
FlutterUnit 的网络请求,使用我 fx 体系中的 fx_dio ,它是基于 dio 的 封装。可以非常方便地管理多个域名,以及检测异常、返回结果,使用的方式也非常简单:
class UnitUpgradeApi implements UpgradeApi {
@override
Future<ApiRet<AppInfo>> fetch(int appId) async {
Host host = FxDio()<ScienceHost>();
String path = ScienceApi.appVersion.path;
return host.get<AppInfo>(
path,
queryParameters: {'app_id': 1, 'os': kAppEnv.os.name},
convertor: AppInfo.fromMap,
);
}
}
最后,视图层只需要监听 UpgradeBloc 的状态,根据其状态数据构建不同的视图即可。比如对于设置按钮右上角的小红点,可以通过 context.watch<UpgradeBloc>()
进行观察,根据状态类是否是 ShouldUpdateState
状态,套上 Badge 组件展示小红点。
class SettingIcon extends StatelessWidget {
const SettingIcon({super.key});
@override
Widget build(BuildContext context) {
UpdateState state = context.watch<UpgradeBloc>().state;
Color tipColor = Colors.redAccent;
Widget child = TolyAction(
style: const ActionStyle.dark(),
onTap: () => context.push(ActionType.settings.path),
child: const Icon(Icons.settings, color: Colors.white, size: 22),
);
return switch (state) {
ShouldUpdateState() => Badge(backgroundColor: tipColor, child: child),
_ => child,
};
}
}
和应用更新相关的其他界面也是类似,就不一一介绍了,感兴趣的可以自己研究一下 FlutterUnit 的源码。
4. FlutterUnit 全局搜索
FlutterUnit 桌面端目前已经支持全局的搜索。在任何地方通过 Ctrl + F
可以打开全局搜索对话框,搜索组件。点击时跳转到对应的组件详情页,得益于 Flutter 导航 2.0, 可以在任意的地方通过路径配置局部切换界面:
另外组件搜索也支持中文名称/节点信息模糊匹配:
全局快捷键通过 Shortcuts 和 Actions 组合实现
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): const GlobalFind(),
},
child: Actions(
actions: <Type, Action<Intent>>{
GlobalFind: CallbackAction<GlobalFind>(onInvoke: _onGloablSearch),
},
child: widget.child,
),
);
}
触发时调用 _onGlobalSearch
弹出 GlobalFindDialog 搜索对话框:
Object? _onGlobalSearch(GlobalFind intent) {
showDialog(context: context, builder: (_) => GlobalFindDialog());
return null;
}
尾声
最后,组件的案例展示代码已经支持局部选择啦,可以更方便的复制案例代码内容:
FlutterUnit 目前已经收录了 354 个组件,后续也会一直持续收录和完善已有的案例代码。2025 年希望可以完成组件数据的国际化。
目前 FlutterUnit 的star 数已经突破 8000 了,感谢大家的喜爱和持续关注 ~
更多文章和视频知识资讯,大家可以关注我的公众号、掘金和 B 站 。