少湖说 | 科技自媒体

互联网,科技,数码,鸿蒙

导读

本文带来非常详细的元服务开发及上架全流程介绍,包含元服务介绍、创建、服务卡片、签名、开发测试、签名打包、上架审核等,一应俱全。

元服务简介

alt text

  • 元服务是一种新的服务提供方式
  • 开发简单、免安装、易于获取和使用
  • 相较于小程序,系统原生,丝滑流畅

轻量应用程序形态,具备秒开直达,纯净清爽;服务相伴,恰合时宜;即用即走,账号相随;一体两面,嵌入运行;原生智能,全域搜索;高效开发,生而可信等特征。

alt text

特点

  • 秒开直达:即开即用,无开屏广告
  • 服务相伴:履约提醒,服务闭环
  • 用完即走:退出无弹窗,服务随账号同步
  • 原生智能:小艺智能,精准触达 全域搜索:系统搜索框
  • 高效开发:UX 组件集、场景化模板等

创建元服务

创建项目

alt text

1.打开 DevEco
2.New -> Create Project
3.选择 Atmoic Service

第一次会提示我们华为账号,这里点击登录,注意这里登录的是开发者账号,也就是用于上架的账号,如果你是企业,那应该使用公司的开发者账号登录。

alt text

点击登录,会跳转到浏览器,在网页中登录开发者账号,并授权允许。

alt text

网页中显示登录成功,然后我们回到 Deveco 即可。

alt text

如果是第一次使用,项目还没有创建,此时需要点击 Register App ID, 创建一个新项目

alt text

填写元服务名称,需要特别注意的是,名称不能重名,具有识别性,不能为广义归纳类,避免诱导用户,具体的要求见参考资料中的审核指南,否则上架审核会被拒。

alt text

点击下一步,选择所属项目,如果还没有创建项目,输入项目名称,点击确认即可创建

alt text

完成 APPID 注册。

alt text

急需创建元服务,点击完成,这样就生成了样板代码。

alt text

图标生成

alt text

在工程中选中模块或文件右键

New -> Image Asset

制作一个 1024 x 1024 px 的正方形图标,自动生成周围的圆圈

编写页面

alt text

@Entry 表示该自定义组件为入口组件,代表当前是一个页面

@Component 表示自定义组件

@State表示组件中的状态变量,状态变量变化会触发UI刷新

aboutToAppear 为生命周期,组件实例化以后,build() 之前

build() 为UI 描述方法

注意:元服务与鸿蒙原生应用完全相同的技术栈,仅仅是可用 API 集合不同,功能相对简单

编写元服务的注意事项

元服务API筛选

不少 API/Kit 无法在元服务中使用,

打开 API参考,可以在左侧勾选,筛选元服务API集

服务卡片

服务卡片

静态卡片交互组件 FormLink

用于静态卡片内部和提供方应用间的交互

action: router 用于跳转,UIAbility 侧通过 params 接收参数

接收传参

alt text

EntryAbility.ets

onCreate 和 onNewWant 中通过 want?.parameters?.params 接收参数

onCreate: UIAbility实例新建

onNewWant:UIAbility实例由后台回到前台,热启动

onWindowStageCreate:UIAbility 创建完成后,进入前台之前,会创建 WindowStage

开发测试

alt text

  1. 模拟器
  2. Previewer
  3. 真机调试
  4. 热重载

投屏工具

DevEco Testing

稳定性测试
性能功耗测试
回归测试
基础质量测试
设备投屏

支持平板的元服务,确保进行过兼容性测试,否则影响上架审核

这部分在《鸿蒙Flutter实战:13-鸿蒙应用打包上架流程》有详细说明。

alt text

  1. DevEco 创建 Key Store
  2. 生成 Key 和 CSR
  3. 在 华为AGC 新增证书,上传 CSR,获得 Cer 证书文件
  4. 创建 Profile 文件
  5. 配置签名文件
  6. 打包 (Build/Huild Apps)

alt text

  1. 回到 AGC,完善应用信息,上传图标
  2. 上传软件包
  3. 完全应用介绍,填写隐私政策、用户协议
  4. 如果是APP,需要提前准备好备案和软著(推荐使用电子版权证)
  5. 提交审核

参考资料

分享一个完整的元服务案例,这个案例高仿了豆瓣的小程序。

简介

整个元服务分为 4-5 个页面,首页为列表页,展示了当前影院热门的电影,点开是一个详情介绍页,里面有影片详情,演职表,相关影片推荐等,热门海报。打开海报是一个完整的海报展示页,点开可以产看大图。
另外,还有一个关于我们的介绍页。

设计

元服务没有使用底部页签,而是把关于我们放置在了页面底部,以较为委婉的方式进行展示。

代码

  1. 查看 entry/src/main/etc/pages/ 目录,整个应用分为了四个页面。
    其路由在 entry/src/main/resources/base/profile/main_pages.json 中配置,路由名与文件名一一对应。
    main_pages.json 所在地目录中,可以看到一个 form_config.json文件,这个用来配置服务卡片,在此按下不表。

页面中多使用行列布局,各种间距优先使用 Blank() 来指定。

在 Album 页面中,使用了网格布局,点击其中一张图片时,会播放幻灯片,这里使用了 @lyb/media-preview 三方库,以下是核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Grid() {
ForEach(this.items, (item: Photo, index: number) => {
GridItem() {
Image(item.image.normal.url)
.width('100%')
.height(140)
.objectFit(images/鸿蒙原生开发手记/imageFit.Cover)
.clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 1.1 })
.visibility(index == this.index ? Visibility.Hidden : Visibility.Visible)
.onClick((event) => {
this.options
.setInitIndex(index)
.setMedias(this.getPreviewResources())
.setIndicator(false)
MediaPreview.open(this.getUIContext(), this.options)
})
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(2)
.rowsGap(2)
.scrollBar(BarState.Off)

  1. common 目录存放一些全局变量,如 Constants.ets 文件,这里使用静态变量。另外还有请求类的简单封装,同样使用静态类作为单例。

为了让导入代码更简洁,同时也 “高内聚低耦合”,使用了 index 文件来导出这个目录下得类和方法。

Request 中的方法使用了泛型,这样可以根据传人类型自动反序列化,减少了样板代码量。
3. components 组件目录。这里存放各封装的小组件。本案例中包含了演职表、版权声明,海报列表,以及相关推荐共 4 个小组件。

Copyright 是一个简单的小组件,显示在页脚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
export struct Copyright {
build() {
Row() {
Image($r('app.media.nutpi')).width(32)
Blank().width(8)
Text('坚果派出品').fontWeight(FontWeight.Bold).fontColor(Color.Gray)
}.justifyContent(FlexAlign.Center)
.width('100%')
.onClick((event) => {
router.pushUrl({ url: 'pages/About' })
})
}
}```

  1. quickactions/pages/QuickActionCard.ets 为服务卡片的页面,这里描述了卡片的 UI,通过 Formlink 监听和触发点击事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FormLink({
action: this.ACTION_TYPE,
abilityName: this.ABILITY_NAME,
params: {
action: this.MESSAGE
}
}) {
Row() {
Text('影院热映').fontSize(14)
.fontColor($r('app.color.card_label'))
Image($r('app.media.icon'))
.width(32)
}
.justifyContent(FlexAlign.SpaceBetween)
.height(this.FULL_HEIGHT_PERCENT)
.width('100%')
.padding({
top: 10,
left: 12,
right: 12,
bottom: 10
})
.backgroundColor($r('app.color.background_color'))
}

在上面的代码中,描述了一个行布局,左侧为图标,右侧为应用名称。

5.viewmodels 为视图数据模型。本案例共 5 个模型,分别是 AppInfoCelebrities, Movie, Photos, Relative.

1
2
3
4
5
6
7
8
export class AppInfo {
logo: string = '';
appName: string = '';
appLinking: string = '';
appId: string = '';
desc: string = '';
type: string = '';
}

AppInfo 是一个简单的应用信息类,包含 logo,应用名称,applink,描述和应用类型等信息。

其他

关于应用创建、服务卡片、打包签名、上架审核等,可以查看往期文章,或下方的参考资料。

完整代码

完整的代码见代码仓库
https://gitee.com/zacks/arkts-ohos-demo

参考资料

鸿蒙原生开发手记系列:

  • [鸿蒙原生开发手记:01-元服务开发]
  • [鸿蒙原生开发手记:02-服务卡片开发]
  • [鸿蒙原生开发手记:01-元服务开发]
  • [鸿蒙原生开发手记:03-元服务开发全流程]
  • 鸿蒙应用打包上架流程

https://gitee.com/zacks/awesome-harmonyos-flutter

导语

整个应用从开发到上架需要一系列工作,包括域名注册、ICP备案、开通开发者账户、App备案,开发、测试、上架、审核以及推广等

域名

在域名服务商注册和购买域名,个人或公司优先选择.com类的域名,像.org等特定的域名谨慎购买,备案有相应的资质或者审批条件,有的后缀域名可能那个无法备案。特殊域名建议查询工信部网站核实是否支持备案。

备案

首先选择个人备案还是企业备案。域名购买后需要实名认证,认证信息需与备案保持一致,如两者不同,则需要先变更域名实名信息。

备案类别有多种,包括域名备案,App备案,小程序备案等,鸿蒙元服务备案目前按App备案处理,两者流程一致。
这里讲一下如何企业备案,登录运营商的备案系统,如阿里云,进入备案,填写企业信息,企业法人证件信息和证件照,网站/App负责人证件信息和证件照,同时验证两个手机号码。
如果是App备案,需要填写应用信息,应用名称、包名、图标、指纹和签名信息等,需要注意的是应名称和包名要和上架的保持一致,否则审核会拒。
提交信息以后,备案服务商会打电话核实信息,阿里云会询问本人身份证号后六位。

服务商审核通过后,会将备案信息提交到工信部,届时会收到一条短信,此时登录工信部网站,填写企业法人身份证号后6位和负责人后6位,以及这个验证码,核验通过后即可。

不同地区的管局要求不一样,有的地区要求网站/App负责人必须是本人,有的则不做要求,可以提取询问备案服务商。

设备

华为手机价格不菲,对有的开发者来说,如果主力机不是华为,则可能是一笔不小的开支。这里需要根据自身情况来做决定。

如果只使用ArkTs开发原生鸿蒙应用,可以考虑使用模拟器,大部分常见可以满足要求,模拟器可用性高。

如果适应了跨端开发框架,如 Flutter,或者设计到与系统底层api交互,或操作硬件,则需要考虑配备真机。

对于设备,优选mate60系列,包括mate60,mate60pro等,价格不敏感考虑mate70及新机型。其次考虑nova系列再次考虑MatePad等。新机觉得贵可以考虑闲鱼二手,购机前确认可以正常升级到NEXT。查询开发者官网的升级计划,确保机型支持Next, ,询问卖家系统无锁,可以正常升级尝鲜。

目前大部分应用优先适配手机端,matepad上的应用较少,即使用来开发,两者在感知上也有一定区别,涉及到屏幕尺寸适配,尤其需要注意,必然审核过程中,容易遭拒。

开发测试

为了提升开发效率,可以使用预览功能,实时预览当前开发的页面或组件,等模块开发完成,再用模拟器或真机进行调试。

开启热重载,默认IDE没有开启,如果需要使用,则先修改运营配置,然后手动点击 H热重载图标。如果要在保存是自动刷新,则在设置里,找到 Auto Save,在里面启用。

应用如果需要访问网络,在module.json5文件中配置网络访问权限。对于元服务,还需要在设备的开发者选项中开启“开发中元服务豁免管控”,上架时,在AGC的项目配置中配置好请求域名。

上架审核

上架时,如果你勾选了适配平板,确保你在平板上测试过,或者你有足够把我不存在适配问题了。审核人员会使用平板仔细检查你的应用,一旦发现问题,应用亦无法上架。

可以提供自测录屏,提高审核通过率。如果应用在设计上,或者使用上有特殊之处,最好在备注项详细说明原因,以便审核人员充分理解。

上架时各项信息如实填写,应用分类和标签也要得当,打包使用生产证书,上传包和勾选时时不要搞错版本,AGC上不使用的软件包即使删除,避免混淆。填写信息时即使点击保存,避免因引导时的网页跳转造成信息丢失。

提交成功后,首先系统进行预审核,这个属于机机审,大约等待一两个小时,审核通过或不通过都会收到通知邮件。然后进入比较长的审核等待期,审核时间一般是工作日时间,包含周六,周日不审。据此可以合理安排提审时间,提早提交排队审核。

公测版本与正式版本是相同的审核流程,彼此独立。

每一次提交,审核人员可能不同,发现的问题也许不同,因人而异,因时而异。

应用第一次上架,审核相对宽松,再次升级提审,则可能愈加严格。

参考资料

https://domain.miit.gov.cn/

作为一名Flutter开发,我骗老板我会前端,她竟然要给我升职加薪

起始

那天,办公室的气氛突然凝固,老板把我叫进她的办公室,眼中带着期待,问:“你会前端吗?”这句话简单,但我知道背后暗藏玄机。我愣了一下,脑海一片空白,内心像是被投进了一颗巨石,掀起层层涟漪。我呢喃了一声:“会一点。” 然而,这话对我来说却显得虚假。毕竟,我只是一个 App 开发,至于前端, 我完全不知。

老板的眼睛立刻亮了起来,笑容灿烂:“那太好了!公司现在缺前端,你顶上吧!”

她笑着拍了拍我的肩膀,转身离开,仿佛这只是日常安排。我站在那儿,心中充满疑问:她为何如此坚定地相信我?我根本不具备前端经验,为什么她会毫不犹豫地把这个任务交给我?

回到工位,我感到一阵窒息,心中的疑惑如迷雾笼罩。我只能埋头苦干,时间似乎在加速,代码一点点成型。尽管不确定这些代码是否符合真正的前端标准,我知道不能停下,不能让自己被质疑。

交差

几周后,老板叫我进办公室,她眼中带着一丝不易察觉的光芒:“前端的工作做得不错,继续保持。以后整个大前端团队的工作,就由你来管理。” 我愣住了,心跳加速,不敢相信自己的耳朵。原本以为这只是过渡的安排,怎么会——

老板继续说:“前端看似简单,但其实非常考验一个人的综合能力。你不仅完成了任务,最重要的是能快速适应新岗位。这种学习能力和抗压能力,正是公司所需要的。” 她的眼神中仿佛有某种洞察力,轻描淡写的话语让我心中掀起波澜。我开始怀疑,是否她早就看穿了我的所有不安与不足?

沉思

走出办公室,手中捏着文件,我心情复杂。那份“认可”让我感到迷茫,升职的喜悦早被困惑压得几乎喘不过气来。我到底是凭借什么得到了这份机会?是我突然爆发的能力,还是老板的一种深思熟虑的决策?她为何如此相信我,甚至将如此重要的任务交给我?

那晚,我辗转反侧,开始怀疑这一切是否偶然。老板的自信与神秘,仿佛她早就预见了这一切。她的每一个微笑、眼神、话语,都让我感觉到某种潜在的安排,仿佛这一切都是早已预定的。

坦白

第二天,我鼓起勇气走进老板办公室:“老板,其实我并不是真正的前端开发,我只会 Flutter 的。之前因为公司缺人,我硬着头皮接了前端的工作,利用 Flutter Web,将 App 代码转制成 H5,同时优化了页面加载速度,解决了文件过大的问题,还适配了微信及小程序。”

老板沉默了几秒,脸上没有惊讶,她微笑道:“其实,我早就知道了。”她轻描淡写地说,仿佛这一切并不重要。“你的能力与前端的要求有差距,但我欣赏你敢于挑战自己的勇气和快速适应的能力。短时间内完成任务,证明了你的潜力。”

她顿了顿,笑容更深:“你是一个有责任心的人,公司的决定不会改变。不过,我希望你能继续努力,成为一个全栈开发。” 她的话像一颗沉重的巨石,再次投进了我内心,掀起了层层波澜。

我突然明白,老板早就看穿了我的困惑和不安。她不只是考察我的能力,更是在试探我的心态——她要知道我是否能承受这份责任,是否具备在职场中持续前进的勇气。

几个月后,我站在前端组的工作台前,回想着那些迷雾重重的日子。老板的眼神依然清晰地浮现在我眼前,仿佛她早已看到了我的未来,而我,也在她的安排中找到了自己的方向。在这片充满谜团的职场森林中,我开始理解她的话:“机会,永远是留给那些有准备的人。”

结语

这就是这个故事的全部,以及这个系列《Flutter Web 实战》的开始,从今天开始,我将详细讲诉几十个日夜星辰,我的研究所得,希望可以帮助到下一个等待机会的你。

参考资料

背景

默认情况下,Flutter 打包 web 以后,首次打开页面需要加载大量的资源,这就需要做首屏加载优化。

渲染引擎

通过分析,canvaskit 和 skwasm 需要加载较大的引擎包,很难优化,目前选择 3.22 版本,故选择 HTML Render 引擎

Flutter Web 计划在 2025 开始弃用 HTML Render。如果是 2025 年的新版本,可以考虑使用 skwasm 引擎。

字体图标裁剪

体积裁剪,通过 bulid apk shaking icon,得到一个裁剪后的字体库,替换调 Flutter Web 打包的对应字体产物

先在 App 项目构建 apk:

1
flutter build apk --tree-shake-icons

找到 build/host/intermediates/assets/release/mergeReleaseAssets/flutter_assets/fonts/MaterialIcons-Regular.otf
将该文档复制到 web/fonts/ 文件夹

文件采样 压缩前 压缩后 压缩率
MaterialIcons-Regular.otf 1.5M 2k 1%

延迟加载

使用延迟加载拆分文件,当前页面不需要的使用的代码延迟加载

Dart 中提供了 defered 关键词,用于延迟加载组件。

参考下方实现一个 DeferredWidget 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import 'dart:async';
import 'package:ealing_widget/common/common_color.dart';
import 'package:flutter/material.dart';

typedef LibraryLoader = Future<void> Function();
typedef DeferredWidgetBuilder = Widget Function();

///延迟加载组件
class DeferredWidget extends StatefulWidget {
DeferredWidget(this.libraryLoader, this.createWidget, {Key? key, Widget? placeholder}) : placeholder = placeholder ?? Container(color: CommonColors.color_widget_background), super(key: key);

final LibraryLoader libraryLoader;
final DeferredWidgetBuilder createWidget;
final Widget placeholder;
// 存储 libraryLoader 对应的 future 数据
static final Map<LibraryLoader, Future<void>> _moduleLoaders = {};
// 存储已经预加载过了的 libraryLoader
static final Set<LibraryLoader> _loadedModules = {};

static Future<void>? preload(LibraryLoader loader) {
if (!_moduleLoaders.containsKey(loader)) {
_moduleLoaders[loader] = loader().then((dynamic _) {
_loadedModules.add(loader);
});
}
return _moduleLoaders[loader];
}

@override
_DeferredWidgetState createState() => _DeferredWidgetState();
}

class _DeferredWidgetState extends State<DeferredWidget> {
Widget? _loadedChild;

@override
void initState() {
if (DeferredWidget._loadedModules.contains(widget.libraryLoader)) {
_onLibraryLoaded();
} else {
DeferredWidget.preload(widget.libraryLoader)?.then((dynamic _) => _onLibraryLoaded());
}
super.initState();
}

void _onLibraryLoaded() {
setState(() {
_loadedChild = widget.createWidget();
});
}

@override
Widget build(BuildContext context) {
return _loadedChild ?? widget.placeholder;
}
}

然后在 GoRouter 路由配置处, 以这种形式使用:

1
2
3
4
5
6
7
8
9
10
11

import '../screens/home/index.dart' deferred as home;

final _router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => ppDeferredWidget(libraryLoader: home.loadLibrary, builder: (() => home.HomeIndexScreen())),
),
],
);

经过以上配置, Flutter Web 打包后,将对 js 文件分割,只有在当前页面打开时,才会加载对应的 js 文件,这就实现了页面组件资源的延迟加载。

alt text

产物对比

经过加载对比可以看到,首屏加载时,原本 2M 左右的 main.dart.js 大小,减小到了 1M 左右,显著提升了首屏静态资源大小。

alt text

加载动画

增加过渡动画,在资源加载过程中使用一个加载动画,优化用户体验。

这里使用 flutter_native_splash 插件,在 app 启动时,显示一个加载动画,在 app 加载完成后,隐藏加载动画。

1
2
3
4
5
6
<body>
<picture id="splash">
<img class="center" width="95" height="100" aria-hidden="true" src="loading.gif" alt="">
</picture>
<script type="text/javascript" src="splash/splash.js"></script>
</body>

增加以下 css 样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
html { height: 100% }

body {
margin: 0;
min-height: 100%;
background-size: 100% 100%;
-webkit-text-size-adjust: 100% !important;
text-size-adjust: 100% !important;
-moz-text-size-adjust: 100% !important;
}

.center {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}

splash/splash.js 的内容如下:

1
2
3
4
5
function removeSplashFromWeb() {
document.getElementById("splash")?.remove();
document.getElementById("splash-branding")?.remove();
document.body.style.background = "transparent";
}

在 Flutter main.dart 中,配置加载动画保持, 我们将在后面手动移除。

1
2
3
void main() {
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
}

在 AppDefere 中,移除加载动画

1
FlutterNativeSplash.remove();

最终效果参考下图展示:

GZIP压缩

开启gzip,压缩静态资源文件。

1
2
3
4
5
6
gzip  on;
gzip_min_length 1k;
gzip_comp_level 5;
gzip_vary on;
gzip_static on;
gzip_types text/plain text/html text/css application/javascript application/x-javascript text/xml application/xml application/xml application/json;

这里配置了压缩文件类型,如 text/plain, html,css, javascript json 等。

Gzip 压缩开启之后,可以在浏览器的开发者工具中,打开网络面板,查看响应头中,有一个 Content-Encoding: gzip 的字段,表示该文件已经被压缩。

经过下表中的采样对比可以看到,压缩率还是很高的。

文件采样 压缩前 压缩后 压缩率
main.dart.js 3.1M 903k 28%
vendor.js 2.6M 667k 25%
app.js 1M 185k 18%

CDN

也可以将静态资源放到 CDN 上,如阿里云等,通过 OSS 存储,然后配置 CDN 加速。需要注意的事,这要做好版本控制,否则会出现缓存问题。

参考资料

与流行前端框架集成

前端有非常多的框架、工具、库,这些都要比 Dart Web 成熟、丰富。所以在将 Fluttter 编译成 Web 以后,若能使用现有的前端技术实现 web 端的特殊需求,肯定事半功倍。

搭建框架

在开始之前,确保你已经安装好了 node 和 npm

使用 create-react-app 初始化项目

首先使用 create-react-app 创建一个前端项目

1
npx create-react-app flutter_web

这些创建以下文件

1
2
3
4
5
6
7
.eslintrc.js
build/
node_modules/
package.json
public/
src/
yarn.lock

这是一个标准的前端项目,不过不用担心,我们不会使用任何 react 技术。

项目配置

为了能自定义 webpack 打包配置,需要安装一个名为 react-app-rewired 的插件,以替换 react-scripts 脚本

使用 react-app-wired 替换

安装 react-app-wired

1
npm install -g react-app-rewired

在根目录创建 config-overrides.js 文件,增加以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = function override(config, env) {
// Remove the default HtmlWebpackPlugin
config.plugins = config.plugins.filter(
(plugin) => !(plugin instanceof HtmlWebpackPlugin)
);

// Add your own HtmlWebpackPlugin instance with your own options
config.plugins.push(
new HtmlWebpackPlugin({
template: 'public/index.html',
minify: {
removeComments: false,
collapseWhitespace: false,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: false,
minifyCSS: true,
minifyURLs: true,
},
})
);

return config;
};

这里面引入一个名为 html-webpack-plugin 的插件,配置了需要压缩的内容。

替换 package.json 中 scripts 部分

1
2
3
4
5
6
7
8
9
  "scripts": {
- "start": "react-scripts start",
+ "start": "react-app-rewired start",
- "build": "react-scripts build",
+ "build": "react-app-rewired build",
- "test": "react-scripts test",
+ "test": "react-app-rewired test",
"eject": "react-scripts eject"
}

初始化 Flutter

在当前项目目录下,执行以下命令初始化 Flutter 项目

1
flutter create --platforms web .

这将创建一个 Flutter 项目,并添加了 web 平台支持。

以下目录由 flutter 创建

1
2
3
4
5
Recreating project ....
pubspec.yaml (created)
lib/main.dart (created)
web/
analysis_options.yaml (created)

集成

现在,虽然两个项目共用一个目录,但我们需要修改一些配置,才将 flutter 项目与前端项目集成在一起工作。

编辑 package.json 文件中scripts/build 处的内容,改为

“rm -rf build && rm -rf web && react-app-rewired build && mv build web”,

同时删除不需要的依赖, 增加 react-app-rewired 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  "dependencies": {
- "cra-template": "1.2.0",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
"react-scripts": "5.0.1"
},
"eslintConfig": {
"extends": [
- "react-app",
- "react-app/jest"
]
},
+ "devDependencies": {
+ "react-app-rewired": "^2.2.1"
+ }

运行 npm installyarn install, 更新依赖。

这行命令的作用是,构建时先清理当前项目目录 build 和 web 目录,构建完成后将前端构建目录改名为 web,以提供给 flutter 进一步构建使用。

最终,经过一番折腾, package.json 文件中的内容如下面所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "flutter_web",
"version": "0.1.0",
"private": true,
"dependencies": {
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-app-rewired start",
"build": "rm -rf build && rm -rf web && react-app-rewired build && mv build web",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},
"eslintConfig": {
"extends": [
]
},
...
"devDependencies": {
"react-app-rewired": "^2.2.1"
}
}

接下来我们复制 web 目录并替换掉 public 目录。

1
2
rm -rf public
cp -r web public

运行 npm run build, 如果能成功生成 web 目录,代表集成成功

前端开发

前面的准备工作完成以后,就可以愉快的开发了!

进入 src 目录,这里面就可以编写我们的前端代码了,也可以使用 npm 的任何 js 库。

为了统一维护 js,我们把 flutter web 的初始化代码从 html 中移到这里。

首先清空 src 目录中的文件,然后新建一个 index.js, 添加以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
window.flutterWebRenderer = "html";
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
entrypointUrl: 'main.dart.js',
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
}
}).then(function(engineInitializer) {
return engineInitializer.initializeEngine();
}).then(function(appRunner) {
return appRunner.runApp();
});
});

项目构建

1
2
3
4
# 构建前端
npm run build
# 构建Flutter
flutter clean && flutter build web --build-number=$VERSION_CODE --build-name=$TAG --web-renderer html --profile --base-href /webapp/

上述命令中, 和 都是环境变量,需要提前设置好

1
2
export VERSION_CODE=1
export TAG=1.0.0

这里需要注意的是,如果你不希望通过子目录访问 Flutter web 应用,那么需要将 base-href 设置为 /,或者移除该选项

1
flutter clean && flutter build web --build-number=$VERSION_CODE --build-name=$TAG --web-renderer html --profile

源码获取

https://gitee.com/zacks/flutter-web-demo

参考资料

微信的 JS-SDK 提供了很多调用微信能力的 API,H5 页面也经常用到。本文以文件上传为为例,介绍了如何在 Flutter Web 项目集成微信 JS-SDK。

配置 JS-SDK

首先,我们需要对微信 JS-SDK 进行初始化配置。以下代码将原本的初始化方法封装为 Promise 形式,便于后续调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 配置js
* @param {*} options
* @returns
*/
export async function configJsSdk(options) {
return new Promise((resolve, reject) => {
wx.config(options);
wx.ready(function () {
resolve();
});
wx.error(function () {
reject();
});
});
}

通过这种方式,我们可以在配置完成后执行后续操作,确保 JS-SDK 的正确初始化。

实现图片上传功能

接下来,我们实现图片上传功能。微信 JS-SDK 提供了 chooseImage 和 uploadImage 两个 API,分别用于选择图片和上传图片。我们将这两个 API 封装为 Promise 形式,方便在 Flutter Web 中调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 上传图片
* @returns
*/
export async function uploadImage() {
return upload(['album', 'camera']);
}

/**
* 拍照上传
* @returns
*/
export async function uploadCamera() {
return upload(['camera']);
}

export async function upload(sourceType) {
return new Promise((resolve, reject) => {
wx.chooseImage({
// 默认9
count: 1,
// 可以指定是原图还是压缩图,默认二者都有
sizeType: ['original', 'compressed'],
// 可以指定来源是相册还是相机,默认二者都有
sourceType: sourceType,
// 返回选定照片的本地 ID 列表,localId可以作为 img 标签的 src 属性显示图片
success: function (res) {
wx.uploadImage({
// 需要上传的图片的本地ID,由 chooseImage 接口获得
localId: res.localIds[0],
// 默认为1,显示进度提示
isShowProgressTips: 1,
// 返回图片的服务器端ID
success: function (res) {
resolve(res.serverId);
},
fail: function (res) {
reject(res);
}
})
}
});
})
}

通过封装,我们可以轻松实现从相册或相机选择图片并上传的功能。

服务端处理文件上传

在客户端上传图片后,服务端需要接收微信返回的 mediaId,并调用微信 API 下载文件。以下是服务端的实现步骤。

引入依赖

首先,在 pom.xml 中引入 wx-java-mp 依赖包:

1
2
3
4
5
6
<!-- 微信公众号 -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>wx-java-mp-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>

下载文件

在服务端,我们可以通过 WxMpService 提供的 API 下载文件:

1
2
3
4
5

@Autowired
private WxMpService wxMpService;

File file = wxMpService.getMaterialService().mediaDownload(encode(request.getMediaId()));

通过 mediaDownload 方法,我们可以根据 mediaId 下载文件,并进行后续处理。

封装与导出

为了方便在 Flutter Web 项目中调用,我们将上述功能封装为一个对象,并导出到全局作用域:

1
2
3
import * as flutterWeb from "./app/index.js";

window.flutterWeb = flutterWeb;

这里可以把 flutterWeb 改为你希望使用的名字。

在 Web 中,可以通过 flutterWeb 对象调用相关方法,我们将在后续文章介绍,如何在 Flutter Web 中调用 Web 中的 API。

总结

本文详细介绍了如何在 Flutter Web 项目中集成微信 JS-SDK,并实现图片上传功能。通过封装 JS-SDK 的 API 和服务端处理逻辑,我们可以轻松实现与微信的深度集成,为用户提供更丰富的功能体验。希望本文能为开发者提供有价值的参考。

参考资料

准备工作

在前面的文章《FlutterWeb实战:04-集成微信JS-SDK提供丰富体验》中,我们介绍了如何集成微信 JS-SDK,实现与微信 H5 交互。

调用小程序API

如果 H5 在微信小程序中打开,还可以调用 JSSDK 提供的小程序相关的 API。以下是可调用的API

1
2
3
4
5
6
7
8
9
wx.miniProgram.navigateTo
wx.miniProgram.navigateBack
wx.miniProgram.switchTab
wx.miniProgram.reLaunch
wx.miniProgram.redirectTo
#向小程序发送消息
wx.miniProgram.postMessag
#获取当前环境
wx.miniProgram.getEnv

统一登录

一种常用的场景是将部分页面以 H5 形式内嵌到小程序的 Webview 提供次级页面服务。这里面涉及到账号打通的问题。

我们希望当用户在小程序中打开 Webview 页面,不需要登录、授权,就可以直接在 H5 中继续相应的操作。这里有一种方式,可以通过 设置 Cookie 来共享登录状态。

统一跳转接口

服务端提供一个API接口,或者称为一个URL地址,形如

https://xxx.com/app/redirect?accessToken={accessToken}&to={to}

这个接口接收两个参数,accessToken代表用户的 Token,to 表示要跳转的页面地址(为确保正确解析,使用urlencode编码)。

假设我们使用 flutter 编写了一个订单页面,其路由为 /order/index,那么这个页面的 URL 为 https://xxx.com/webapp/#/order/index, 这里面我们使用二级目录托管 Flutter Web 页面,让他与 API 使用相同域名。

当用户在小程序中打开 Webivew 的页面,我们希望用户打开 https://xxx.com/webapp/#/order/index 页面,但为了保持登录状态,我们不直接打开这个页面,而是需要通过统一跳转接口中转,

也就是用户打开的是 https://xxx.com/app/redirect?accessToken={accessToken}&to=/webapp/#/order/index,在这个接口中,服务端接收两个参数,并向客户端设置 Cookie,同时向客户端发起一个301临时重定向,
小程序的Webview在收到响应后,要自动进行跳转,最终也就跳转到了我们的目的页面,同时本地Cookie中保存的AccessToken,这样也就实现了登录状态共享。

服务端的代码类似如下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 定义跳转接口
@GetMapping("/app/redirect")
public ResponseEntity<Void> redirectToTargetPage(
@RequestParam("accessToken") String accessToken,
@RequestParam("to") String targetUrl,
HttpServletResponse response) throws IOException {

// 创建 Cookie 并设置值
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
// 设置 Cookie 的路径,保证整个站点有效
accessTokenCookie.setPath("/");
// 设置 Cookie 的过期时间,单位是秒,这里设置为 1小时
accessTokenCookie.setMaxAge(3600);

// 将 Cookie 添加到响应中
response.addCookie(accessTokenCookie);

// 设置 301 临时重定向
HttpHeaders headers = new HttpHeaders();
headers.add("Location", targetUrl);

// 返回 301 状态码和 Location 头部,触发客户端重定向
return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
}

这里需要注意的是,接口域名必须和跳转页面的域名一致,否则无法共享 Cookie。

参考资料

准备工作

Dart 调用 JS

这里面我们使用 js 库来实现 JS 调用 Dart,首先添加依赖:

1
2
  dependencies:
+ js: ^0.6.4

在 Dart 侧定义调用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@JS()
@anonymous
class WxConfigOption {
/// 开启调试模式,调用的所有 api 的返回值会在客户端 alert 出来,若要查看传入的参数,可以在 pc 端打开,参数信息会通过 log 打出,仅在 pc 端时才会打印。
external bool? debug;

/// 公众号的唯一标识
external String appId;

/// 生成签名的时间戳
external num timestamp;

/// 生成签名的随机串
external String nonceStr;

/// 签名
external String signature;

/// 需要使用的 JS 接口列表
external List<String> jsApiList;

/// 需要跳转的标签类型
external List<String> openTagList;

external factory WxConfigOption({
bool? debug,
required String appId,
required num timestamp,
required String nonceStr,
required String signature,
required List<String> jsApiList,
required List<String> openTagList,
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@JS()
class Promise<T> {
external Promise(void Function(void Function(T result) resolve, Function reject) executor);
external Promise then(void Function(T result) onFulfilled, [Function onRejected]);
}

/// 声明调用方法
@JS("flutterWeb")
class FlutterWeb {
/// 配置js-sdk
external static Promise<void> configJsSdk(WxConfigOption options);

/// 上传图片
external static Promise<String> uploadImage();
}

在《FlutterWeb实战:04-集成微信JS-SDK提供丰富体验》中,我们介绍了如何封装微信的 JS-SDK 方法,供 Flutter 调用。

最后在 JS 侧导出了被调用方法:

1
2
3
import * as flutterWeb from "./index.js";

window.flutterWeb = flutterWeb;

这样就可以在 Dart 侧调用 JS 方法了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Future resolveSdkSign() {
final completer = Completer<void>();
FlutterWeb.configJsSdk(WxConfigOption(
appId: appId,
timestamp: timestamp,
nonceStr: nonceStr,
signature: signature,
jsApiList: ['chooseImage','uploadImage'],
openTagList: [
'wx-open-launch-app',
'wx-open-launch-weapp'
]
)).then(allowInterop(completer.complete),
allowInterop(completer.completeError));
return completer.future;
}

可以以这种形式调用:

配置 JS-SDK

1
resolveSdkSign().then((_) {})

上传图片

1
2
FlutterWeb.uploadImage()
.then(allowInterop(completer.complete), allowInterop(completer.completeError));

JS 调用 Dart

1
window.jsOnEvent("events.page.active");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@JS('jsOnEvent')
external set _jsOnEvent(void Function(dynamic event) f);

class PlatformCallWebPlugin {
static void registerWith(Registrar registrar) {
final MethodChannel channel = MethodChannel(
'nicestwood.com/forest', const StandardMethodCodec(), registrar);
channel.setMethodCallHandler(handleMethodHandler);

//Sets the call from JavaScript handler
_jsOnEvent = allowInterop((dynamic event) {
//
if (event == 'events.page.active') {
// do something
}
});
}
}

参考资料

Flutter Web 开发打包后,可以手动发布到服务器上,通过 nginx 来托管静态页面。本文将介绍如何将这一过程自动化。

整体思路

使用脚本自动化构建,然后使用 Docker 打包成镜像,最后部署在服务器上。

自动化构建

这里使用 GitLab-CI 来自动化构建。

整个流水线分为四步,分别是前端构建、Flutter Web 构建、Docker 镜像打包、以及部署。

前端构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
build-js:
image: zacksleo/node:19
stage: .pre
script: |-
CI_COMMIT_TAG=${CI_COMMIT_TAG:-latest}
cd packages/apps/web
yarn install
sed -i "s/main.dart.js/main.dart.js?v=$CI_COMMIT_SHORT_SHA/g" src/index.js
sed -i "s/flutter.js/flutter.js?v=$CI_COMMIT_SHORT_SHA/g" public/index.html
yarn build
artifacts:
paths:
- packages/apps/web/web
expire_in: 60 mins

Flutter Web 构建

这里使用了 flutter build web 命令来构建 Flutter Web 应用,构建后批量对文件重命名,统一增加 Commit Hash 后缀,以解决缓存问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
build-web:
stage: build
script: |-
CI_COMMIT_TAG=${CI_COMMIT_TAG:-latest}
echo $(git log -1 --pretty=%s | tail -1) > ./release.log
cd packages/apps/web
echo -e '\nHIDE_APP_BAR=true' >> env
flutter build web --pwa-strategy none --build-number=$VERSION_CODE --build-name=$TAG --web-renderer html --base-href /webapp/
cp .deploy/flutter.js build/web
# 对part文件重命名,以解决缓存问题
sed -i "s/.part.js/.$CI_COMMIT_SHORT_SHA.part.js/g" build/web/main.dart.js
sed -i "s/.part.js/.$CI_COMMIT_SHORT_SHA.part.js/g" build/web/flutter_service_worker.js
for file in build/web/main.dart.js_* ; do mv $file ${file//part/$CI_COMMIT_SHORT_SHA.part} ; done
needs: ["build-js"]
dependencies:
- build-js
artifacts:
paths:
- packages/apps/web/build/web
- ./release.log
expire_in: 120 mins

Docker 镜像打包

这里使用 Docker 来打包镜像,然后推送到 Docker 镜像仓库。打包时,替换了压缩版本的字体图标文件。

Dockerfile 文件配置

1
2
3
FROM nginx:alpine
COPY .deploy/nginx.conf /etc/nginx/nginx.conf
COPY build/web /usr/share/nginx/html

GitLab-CI 文件配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dockerize:
stage: dockerize
image: docker:latest
needs: ["build-web"]
dependencies:
- build-web
before_script: []
script:
- if [[ -z "$CI_COMMIT_TAG" ]];then
- CI_COMMIT_TAG="latest"
- fi
- cd packages/apps/web
- cp build/web/fonts/MaterialIcons-Regular.otf build/web/assets/fonts
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
- docker build --build-arg DEPLOY_ENV=$DEPLOY_ENV -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG || true

部署

这里使用了 rsync 来同步部署文件到服务器上,然后使用 docker-compose 拉取镜像和来启动服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
prod-web:
image: zacksleo/node
stage: release
needs: ["dockerize"]
variables:
DEPLOY_SERVER: "10.10.10.10"
SSH_PORT: 22
script:
- cd packages/apps/web/.deploy
- CI_COMMIT_TAG=${CI_COMMIT_TAG:-latest}
- SSH_PORT=${SSH_PORT:-22}
- rsync -rtvhze "ssh -p $SSH_PORT" . root@$DEPLOY_SERVER:/data/$CI_PROJECT_NAME --stats
- ssh -p $SSH_PORT root@$DEPLOY_SERVER "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
- ssh -p $SSH_PORT root@$DEPLOY_SERVER "export COMPOSE_HTTP_TIMEOUT=120 && export DOCKER_CLIENT_TIMEOUT=120 && cd /data/$CI_PROJECT_NAME && echo -e '\nTAG=$CI_COMMIT_TAG' >> .env && docker-compose pull $MODULE && docker-compose stop $MODULE && docker-compose rm -f $MODULE && docker-compose up -d $MODULE"
needs: ["dockerize"]

nginx 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
user  nginx;
worker_processes 1;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;

gzip on;
gzip_min_length 1k;
gzip_comp_level 5;
gzip_vary on;
gzip_static on;
gzip_types text/plain text/html text/css application/javascript application/x-javascript text/xml application/xml application/xml application/json;

client_max_body_size 2M;

server {
listen 80;
root /usr/share/nginx/html;
location /webapp/ {
rewrite ^/webapp(/.*)$ $1 last;
index index.html index.htm
try_files $uri $uri/index.html $uri/ =404;
}
}
}

0%