一、把"分享"做成一个闭环
普通分享是把图片甩出去,链路到这就断了。我们想做的多一点:海报上带一个二维码,对方扫码就能拿到你这张照片用的模板,形成"我拍 → 你扫 → 你拍同款"的闭环。
拆开看,这个功能有四个环节,每个都有坑:
- 合成:把照片、水印、文案、二维码拼成一张固定尺寸的海报,并捕获成 PNG;
- 渲染:海报有多种版式可切换,预览要跟手、导出要清晰;
- 编码:二维码里放什么,才能扫得动又扫得准;
- 落地:保存到相册、调起系统分享,还要在鸿蒙上跑通。
下面按这四步讲。
二、合成:RepaintBoundary.toImage 与"1080 宽"导出口径
Flutter 里把一棵 Widget 树截成图片,标准做法是 RepaintBoundary + RenderRepaintBoundary.toImage。我们把它收敛进一个通用生成器 PosterGenerator,调用方只传内容 Widget 和一个 GlobalKey:
final boundary = key.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null || boundary.size.isEmpty) return null;
final pixelRatio = (kPosterExportWidth / boundary.size.width).clamp(2.0, 4.0);
return boundary.toImage(pixelRatio: pixelRatio);
这里有两个刻意的决定:
其一,导出倍率按"目标宽度"反算,而不是固定 3x。 我们支持全屏 9:16、3:4、1:1、4:3、16:9 五种比例(PosterRatio 由图片宽高比归类)。如果无脑固定 pixelRatio: 3,9:16 竖图按设计逻辑宽算出来只有 900px 宽,清晰度不够。改成"统一以 ≥1080px 物理宽出图":
static const double kPosterExportWidth = 1080;
final ratio = (kPosterExportWidth / size.width).clamp(2.0, 4.0);
clamp(2.0, 4.0) 是防呆:小画布别算出离谱的大倍率,大画布也别低于 2x。
其二,捕获的是"内容本体",不是预览容器。 预览区通常套了 FittedBox / Center / 背景色,如果连容器一起截,导出图会带上容器的背景填充和边距。所以我们让每个版式各自持有一个 GlobalKey,toImage 只盯海报本体——这样预览缩放(视觉)和导出分辨率(像素)彻底解耦。
三、离屏预渲染:为什么要把海报画到屏幕外
海报有多个版式,横向 PageView 切换。如果每个版式都用 live Widget 渲染,在鸿蒙设备上左右滑动会明显掉帧——一页海报里可能有封面解码、渐变、阴影、二维码,同时渲染两页开销不小。
解法是把每个版式离屏预渲染成位图,PageView 里改成平移 RawImage:
// 挂到屏幕外渲染,避免渲染瞬间闪现
final entry = OverlayEntry(
builder: (ctx) => Positioned(
left: 30000,
top: 0,
child: RepaintBoundary(
key: renderKey,
child: Material(type: MaterialType.transparency, child: poster),
),
),
);
overlay.insert(entry);
try {
final boundary = await _settleBoundary(renderKey);
return boundary?.toImage(pixelRatio: pixelRatio);
} finally {
entry.remove(); // 捕获完立刻移除
}
几个细节值得说:
- 用
OverlayEntry+Positioned(left: 30000)挂到屏幕外,比Offstage好:Offstage不参与布局(拿不到尺寸),而我们要的是"正常布局、只是看不见"。 - 外面包一层
Material(type: MaterialType.transparency)。离屏的 Overlay 没有Material/Scaffold祖先,海报里的Text拿不到DefaultTextStyle,某些引擎上会渲染出黄色双下划线。包一层透明Material提供默认文本样式,不影响透明背景和捕获结果。 - 捕获后立即
entry.remove(),别把离屏树一直挂在树上。
还有个容易踩的坑:PageView 相邻两页会同时挂在渲染树上,如果共用同一个 GlobalKey,会触发 Duplicate GlobalKey,Flutter 直接截断整棵子树。 所以每个版式页用独立的 key:
final Map<int, GlobalKey> _styleContentKeys = {};
// ...
RepaintBoundary(
key: _styleContentKeys.putIfAbsent(i, () => GlobalKey()),
child: style.builder(_data),
)
四、等图片解码:一个"遍历渲染树"的土办法
toImage 有个隐性前提:这棵子树里的图片必须已经解码完成,否则截出来是空白或占位图。海报里有照片封面、二维码,都可能异步解码。
Flutter 没有直接给出"这棵树里的图片是否都 ready"的公开 API,我们的做法是遍历渲染树,找还在加载的 RenderImage:
bool _treeImagesReady(RenderObject root) {
var pending = false;
void visit(RenderObject node) {
if (!pending && node is RenderImage && node.image == null) pending = true;
if (!pending) node.visitChildren(visit);
}
visit(root);
return !pending;
}
再配合"每 16ms 一帧、超时 2500ms"的轮询:
while (sw.elapsed < timeout) {
await Future<void>.delayed(const Duration(milliseconds: 16));
await WidgetsBinding.instance.endOfFrame;
final b = key.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (b == null || !b.attached) return null;
if (_treeImagesReady(b)) return b;
}
endOfFrame 保证等到本帧画完再判断,避免刚挂上就下结论。超时后兜底返回上一次拿到的 boundary——宁可清晰度打折,也别让用户卡在"生成中"。
五、二维码:内容越长,越扫不动
海报里的二维码尺寸只有几十到一百多 px(各版式不同),这决定了二维码内容不能长。
我们二维码放的是一个自定义 scheme 的分享链接:
lumira://tpl/{base64url(json)}
关键在 json 用哪个版本。模板导出有两种:
| 格式 | 内容 | 体积 | 模块数 |
|---|---|---|---|
.pptpl | 完整 6 区段,内嵌 base64 封面 / 剪影 | 约 2261 字节 | 约 157 模块 |
.lumira | 简化版,仅基础信息 | 约 445 字节 | 约 73 模块 |
如果海报二维码用完整 .pptpl,2261 字节会生成约 157 模块的高密度码;缩到海报的小尺寸后,直接糊成一团黑点,扫不动。所以海报一律用简化链接:
/// 构建海报二维码内容:统一使用低密度简化 .lumira 链接。
static String buildPosterQrData(TemplateRecord record) =>
buildShareLink(record, usePptpl: false);
渲染用 qr_flutter:
QrImageView(
data: buildTemplatePosterQrData(template),
version: QrVersions.auto, // 让库按数据量自选版本
size: 140,
eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: tokens.textPrimary),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: tokens.textPrimary,
),
)
解析侧要把 lumira://tpl/{base64} 和 https://lumira.app/tpl/... 两种形式都吃下(TemplateShareCode.parseLink),并保持生成 / 解析往返一致——这条有单测盯着。
六、保存与分享:三平台降级链
保存到相册:
- iOS / Android:走
saver_gallery的SaverGallery.saveImage; - HarmonyOS:
saver_gallery没有 ohos 实现,会抛MissingPluginException。我们捕获后降级到自建原生通道:
const channel = MethodChannel('lumira/photo_saver');
final result = await channel.invokeMethod('saveToAlbum', {'path': file.path});
调起分享:统一走一层 SafeShare 包装器。它的降级顺序是:
- iOS / ohos:先试原生通道
MethodChannel('lumira/system_share')的shareFiles; - 失败或非上述平台:退回
share_plus的Share.shareXFiles; - 再抛
MissingPluginException:降级到"把文件路径复制到剪贴板"并 toast 提示。
try {
await Share.shareXFiles(files, subject: subject, text: text);
} on MissingPluginException {
await _fallbackToClipboard(files.first.path);
}
之所以先走原生通道,是因为鸿蒙上 pub.dev 版 share_plus 缺原生实现;先试原生成功率高,失败再逐级回退,用户感知只是"分享面板换了形式",不会白屏或报错。
七、踩坑清单
- 离屏 Overlay 无
DefaultTextStyle→ 文字出现黄色双下划线:包透明Material; - PageView 相邻页共用
GlobalKey→Duplicate GlobalKey截断子树:按页独立 key; toImage前图片没解码完 → 截出空白:先遍历RenderImage确认 ready;- 导出固定 3x → 9:16 竖图偏糊:改按"1080 宽"反算倍率;
- 捕获预览容器 → 导出图带背景和边距:改"内容级捕获";
- 鸿蒙
saver_gallery/share_plus无原生实现 →MissingPluginException:分别降级到自建通道 / 剪贴板; - 二维码内容过长 → 小尺寸糊成一团:海报只用简化链接。
八、小结
"一键生成分享海报"这句话,拆开是四件事:合成要定死导出口径(1080 宽 + 内容级捕获)、渲染要离屏预渲染成位图、二维码要控长度、落地要给鸿蒙留降级链。把二维码放进海报,分享就不再是终点,而是下一张照片的起点。
目前「如画 Lumira」在鸿蒙应用市场已上架,iOS 版正在审核、即将上架;应用商店搜索「如画 Lumira」即可找到。