Flutter 一键生成带二维码的分享海报:海报合成 + 分享闭环的实现思路

4 阅读6分钟

一、把"分享"做成一个闭环

普通分享是把图片甩出去,链路到这就断了。我们想做的多一点:海报上带一个二维码,对方扫码就能拿到你这张照片用的模板,形成"我拍 → 你扫 → 你拍同款"的闭环。

拆开看,这个功能有四个环节,每个都有坑:

  1. 合成:把照片、水印、文案、二维码拼成一张固定尺寸的海报,并捕获成 PNG;
  2. 渲染:海报有多种版式可切换,预览要跟手、导出要清晰;
  3. 编码:二维码里放什么,才能扫得动又扫得准;
  4. 落地:保存到相册、调起系统分享,还要在鸿蒙上跑通。

下面按这四步讲。

二、合成: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 包装器。它的降级顺序是:

  1. iOS / ohos:先试原生通道 MethodChannel('lumira/system_share') 的 shareFiles;
  2. 失败或非上述平台:退回 share_plus 的 Share.shareXFiles;
  3. 再抛 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」即可找到。