书接上篇Flutter for OpenHarmony年味+实用实战应用|搭建【多 Tab 应用】基础工程 + 实现【弧形底部导航】,本篇基于
screenshot、**share_plus(鸿蒙化)** 与自定义贴纸逻辑,实现分类列表,详情页祝福卡 ( 添加/拖动贴纸 ,一键截图分享)功能。
一、三方库核心价值与适用场景
1.1 本篇用到的Flutter三方库
| 库名 | 核心价值 | 适用场景 |
|---|---|---|
screenshot |
将任意 Widget 截取为图片(Uint8List),支持 pixelRatio、delay |
祝福卡片生成图片、分享前截图、鸿蒙/多端通用 |
share_plus(鸿蒙化分支) |
调起系统分享面板,分享文本/图片/文件到微信、朋友圈、短信等 | 祝福卡片分享、鸿蒙端需社区鸿蒙化实现 |
path_provider / cross_file |
临时目录路径;XFile 供share_plus使用 |
截图写入临时文件再分享,由share_plus依赖带入 |
为什么不用flutter_card_swiper / sticker_view :当前采用"列表 → 详情单卡"结构,更利于分类浏览;贴纸用"固定槽位 + GestureDetector 拖动"自实现,避免三方贴纸库在鸿蒙上的兼容与构建问题。
1.2 整体流程概览

二、环境适配
2.1 支持的 Flutter 版本
| 库 | 最低 Flutter / Dart | 本教程验证 |
|---|---|---|
screenshot ^3.0.0 |
无特殊要求 | Flutter 3.x + Dart 3.x ✅ |
share_plus(鸿蒙化分支) |
随 Flutter for OpenHarmony | br_share_plus-v10.1.1_ohos ✅ |
2.2 多端适配情况

- screenshot :基于
RenderRepaintBoundary,Android / iOS / 鸿蒙 均可使用,无需平台通道。 - share_plus :本实战使用 OpenHarmony-SIG 鸿蒙化分支(
br_share_plus-v10.1.1_ohos),鸿蒙上可正常调起分享。 - 鸿蒙注意点:需完成鸿蒙工程配置与签名后再打包 HAP;分享面板不弹出时请确认使用上述 git 分支。
三、集成步骤
3.1 第一步:pubspec.yaml 配置
在项目根目录的 pubspec.yaml 的 dependencies: 下增加screenshot与share_plus(鸿蒙化)。
为什么 share_plus 用 git :鸿蒙端需使用 OpenHarmony-SIG 的鸿蒙化分支,才能调起鸿蒙系统分享面板。
为什么不要单独写 path_provider :鸿蒙化share_plus会依赖特定版本的path_provider,单独写易产生版本冲突。
Flutter 端代码(项目根目录pubspec.yaml片段):
yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
font_awesome_flutter: ^10.12.0
screenshot: ^3.0.0
share_plus:
git:
url: https://gitcode.com/openharmony-sig/flutter_plus_plugins.git
path: packages/share_plus/share_plus
ref: br_share_plus-v10.1.1_ohos
3.2 第二步:依赖安装
在项目根目录执行:
bash
flutter pub get
看到 Got dependencies! 即表示成功。为什么必须执行 :git 依赖与版本解析在此步完成,否则 IDE 会报Target of URI doesn't exist。
3.3 第三步:基础初始化(无额外初始化)
screenshot、share_plus 不需要 在 main() 或 runApp 前做额外初始化,在使用页面按需import即可。
Flutter 端代码(祝福详情页等dart文件顶部):
dart
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:cross_file/cross_file.dart';
3.4 第四步:核心实现与 API 用法
整体流程:列表页(分类)→ 点击某条 → 详情页(卡片 + 贴纸 + 截图分享) 。
为什么采用「列表 + 详情」:分类多、祝福语数量大时,列表更利于快速定位;详情页只承载一张卡,贴纸与截图逻辑简单、状态清晰。
3.4.1 数据模型:为什么这么设计
祝福语需要按分类展示 、列表中显示简短预览 、详情页显示完整文案与渐变 ,因此用「分类 + 卡片」两级结构。
为什么listPreview用getter:与正文同一数据源,不重复维护两套文案。
Flutter 端代码(lib/pages/blessing_card_data.dart 核心):
dart
import 'package:flutter/material.dart';
/// 📇 单张祝福卡片数据
class BlessingCardData {
final String categoryLabel;
final String text;
final List<Color> gradientColors;
final AlignmentGeometry gradientBegin;
final AlignmentGeometry gradientEnd;
const BlessingCardData({
required this.categoryLabel,
required this.text,
required this.gradientColors,
this.gradientBegin = Alignment.topLeft,
this.gradientEnd = Alignment.bottomRight,
});
/// 列表预览:取前若干字
String get listPreview {
if (text.length <= 28) return text;
return '${text.substring(0, 28)}...';
}
}
/// 🎴 分类 + 卡片列表
class BlessingCategory {
final String label;
final List<BlessingCardData> cards;
const BlessingCategory({required this.label, required this.cards});
}
/// 通用渐变
const _gradientUniversal = [Color(0xFFFFF8F0), Color(0xFFFFE4D6)];
const _gradientFun = [Color(0xFFFFE4E1), Color(0xFFFFECB3)];
const _gradientWarm = [Color(0xFFFFFDE7), Color(0xFFFFCCBC)];
const _gradientBiz = [Color(0xFFE3F2FD), Color(0xFFFFE0B2)];
const _gradientRed = [Color(0xFFFFCDD2), Color(0xFFEF9A9A)];
/// 🎴 全部祝福卡片(分类 + 文案)
class BlessingCardPresets {
static const List<BlessingCategory> categories = [
BlessingCategory(
label: '通用百搭款',
cards: [
BlessingCardData(
categoryLabel: '通用百搭款',
text: '马踏祥云迎新春,万事顺遂福满门,2026 新春快乐!',
gradientColors: _gradientUniversal,
),
// ...
],
),
BlessingCategory(
label: '马年趣味款',
cards: [
BlessingCardData(
categoryLabel: '马年趣味款',
text: '2026 烦恼马上消,好运马上到,暴富马不停蹄!',
gradientColors: _gradientFun,
),
// ...
],
),
BlessingCategory(
label: '温情走心款',
cards: [
BlessingCardData(
categoryLabel: '温情走心款',
text: '小时候您是我的千里马,长大后我做您的守护马,爸妈新年安康,喜乐常伴~',
gradientColors: _gradientWarm,
),
// ...
],
),
BlessingCategory(
label: '商务得体款',
cards: [
BlessingCardData(
categoryLabel: '商务得体款',
text: '策马扬鞭启新程,携手并进共繁华,2026 愿我们合作共赢,马到功成!',
gradientColors: _gradientBiz,
),
// ...
],
),
BlessingCategory(
label: '红包短句款',
cards: [
BlessingCardData(
categoryLabel: '红包短句款',
text: '马年吉祥,万事顺意',
gradientColors: _gradientRed,
),
// ...
],
),
];
/// 扁平列表(用于统计或遍历)
static List<BlessingCardData> get allCards =>
categories.expand((c) => c.cards).toList();
}
3.4.2 列表页
为什么用ListView.builder + 按分类分组:
- 分类与每类下卡片数可多可少,
builder按需构建; - 结构清晰,后续加分类或改样式只需动数据与单个
_buildSection。
Flutter 端代码(lib/pages/blessing_page.dart):
dart
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'blessing_card_data.dart';
import 'blessing_detail_page.dart';
/// 🎴 春节祝福列表页:以列表卡片形式展示全部分类与祝福文案,点击进入详情页进行贴纸与截图分享。
class BlessingPage extends StatelessWidget {
const BlessingPage({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFF8F0), Color(0xFFFFE4D6)],
),
),
child: SafeArea(
child: Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'春节祝福',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFFC41E3A),
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: BlessingCardPresets.categories.length,
itemBuilder: (context, categoryIndex) {
final category = BlessingCardPresets.categories[categoryIndex];
return _buildSection(context, category);
},
),
),
],
),
),
);
}
Widget _buildSection(BuildContext context, BlessingCategory category) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, top: 16, bottom: 8),
child: Row(
children: [
FaIcon(FontAwesomeIcons.gift, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
category.label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF5C4033),
),
),
],
),
),
...category.cards.map((card) => _buildCardTile(context, card)),
],
);
}
Widget _buildCardTile(BuildContext context, BlessingCardData card) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(12),
elevation: 2,
shadowColor: Colors.black26,
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => BlessingDetailPage(card: card),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(
child: Text(
card.listPreview,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
height: 1.4,
color: Color(0xFF5C4033),
),
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: Colors.grey.shade600, size: 22),
],
),
),
),
),
);
}
}

为什么用InkWell + Material:保证点击涟漪与圆角裁剪一致,体验统一。
3.4.3 详情页
- 为什么卡片固定为9:16:便于竖屏分享到社交软件时比例统一、不变形。
- 为什么用
Screenshot包住整张卡片 :只截卡片区域,不截AppBar和底部按钮,分享图更干净。卡片用Container固定宽高(如 width: 320, height: 320/(9/16)),内层Stack:先Padding + Center放正文,再按_stickers用Positioned叠贴纸。贴纸槽位与拖动见下条。 - 为什么用「固定矩形槽位」而不是纯随机坐标 :若用
Align(alignment: randomAlignment),贴纸以中心点对齐且有宽高,两个随机槽位换算到像素后仍可能重叠。使用每张贴纸分配固定大小矩形格子 (如 88×88),用Positioned(left, top, width, height)限制在格子里,从根上避免重叠。 - 为什么槽位放在卡片左右两侧:中央留给祝福文案,贴纸只放左右两侧上下两行,不遮挡文字;左右各 4 格共 8 个,与 8 种贴纸类型一致。
- 为什么还要支持拖动 :固定槽位保证初始不重叠;用户可拖动微调,用
GestureDetector.onPanUpdate累加位移,并用clamp限制在卡片范围内。 - 为什么用
Rect存位置 :贴纸既有位置又有宽高,用Rect一次表达,便于做边界clamp。 - 为什么
shuffle槽位:同一张卡每次打开贴纸出现顺序随机,排布不呆板。 - 为什么用
HitTestBehavior.opaque:整块区域都能响应拖动,不会只点到文字才动。 - 为什么先写文件再分享 :鸿蒙/系统分享接口需要文件路径或
XFile,不能直接传Uint8List,故先把capture()得到的字节写入临时目录,再用Share.shareXFiles([XFile(path)], ...)调起。 - 为什么
capture要带delay:给Widget一帧绘制时间,避免截到空白。 - 为什么
pixelRatio要clamp(2.0, 4.0):兼顾清晰度与文件体积。
Flutter 端代码(lib/pages/blessing_detail_page.dart):
dart
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:screenshot/screenshot.dart';
import 'package:cross_file/cross_file.dart';
import 'package:share_plus/share_plus.dart';
import 'blessing_card_data.dart';
import 'blessing_page_stickers.dart';
/// 单张祝福卡详情页:展示文案 + 贴纸 + 截图分享。贴纸可拖动自定义位置,不覆盖正文。
class BlessingDetailPage extends StatefulWidget {
final BlessingCardData card;
const BlessingDetailPage({super.key, required this.card});
@override
State<BlessingDetailPage> createState() => _BlessingDetailPageState();
}
class _BlessingDetailPageState extends State<BlessingDetailPage> {
final ScreenshotController _screenshotController = ScreenshotController();
final List<StickerItem> _stickers = [];
final Random _random = Random();
/// 卡片尺寸(与 _buildCard 一致,用于计算贴纸区域)
static const double _cardWidth = 320;
static const double _cardHeight = 320 / (9 / 16); // 9:16
/// 每个贴纸占用的固定格子大小,保证任意贴纸不超出格子、格子间有间隙
static const double _slotSize = 88;
static const double _slotMargin = 24;
static const double _slotGap = 20;
/// 预定义 8 个互不重叠的矩形区域(左 4 + 右 4,避开中央正文),单位为像素
static List<Rect> _buildStickerRects() {
final left = _slotMargin;
final right = _cardWidth - _slotMargin - _slotSize;
final top = _slotMargin;
final bottom = _cardHeight - _slotMargin - _slotSize;
// 上下两行:上排贴边,下排贴边,中间留空给文字
final topRow = top;
final bottomRow = bottom;
final upperMid = top + _slotSize + _slotGap;
final lowerMid = bottom - _slotSize - _slotGap;
return [
Rect.fromLTWH(left, topRow, _slotSize, _slotSize),
Rect.fromLTWH(right, topRow, _slotSize, _slotSize),
Rect.fromLTWH(left, upperMid, _slotSize, _slotSize),
Rect.fromLTWH(right, upperMid, _slotSize, _slotSize),
Rect.fromLTWH(left, lowerMid, _slotSize, _slotSize),
Rect.fromLTWH(right, lowerMid, _slotSize, _slotSize),
Rect.fromLTWH(left, bottomRow, _slotSize, _slotSize),
Rect.fromLTWH(right, bottomRow, _slotSize, _slotSize),
];
}
static final List<Rect> _stickerRects = _buildStickerRects();
/// 打乱后的槽位顺序,每张卡随机顺序
late List<Rect> _shuffledRects;
@override
void initState() {
super.initState();
_shuffledRects = List<Rect>.from(_stickerRects)..shuffle(_random);
}
/// 取当前应使用的矩形槽位(初始位置,后续可拖动)
Rect _nextStickerRect() {
final index = _stickers.length.clamp(0, _shuffledRects.length - 1);
return _shuffledRects[index];
}
/// 将贴纸位置限制在卡片内
Rect _clampStickerRect(Rect rect) {
final left = rect.left.clamp(0.0, _cardWidth - rect.width);
final top = rect.top.clamp(0.0, _cardHeight - rect.height);
return Rect.fromLTWH(left, top, rect.width, rect.height);
}
void _updateStickerPosition(int index, Offset delta) {
if (index < 0 || index >= _stickers.length) return;
final item = _stickers[index];
final newRect = _clampStickerRect(Rect.fromLTWH(
item.rect.left + delta.dx,
item.rect.top + delta.dy,
item.rect.width,
item.rect.height,
));
setState(() {
_stickers[index] = StickerItem(type: item.type, child: item.child, rect: newRect);
});
}
void _addSticker(StickerType type, Widget child) {
if (_stickers.any((e) => e.type == type)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('本卡已有「${stickerTypeLabel(type)}」'), duration: const Duration(seconds: 2)),
);
}
return;
}
setState(() {
final rect = _nextStickerRect();
_stickers.add(StickerItem(type: type, child: child, rect: rect));
});
}
Future<void> _captureAndShare() async {
final pixelRatio = MediaQuery.of(context).devicePixelRatio.clamp(2.0, 4.0);
final bytes = await _screenshotController.capture(
pixelRatio: pixelRatio,
delay: const Duration(milliseconds: 150),
);
if (bytes == null || !mounted) return;
final dir = await getTemporaryDirectory();
final path = '${dir.path}/blessing_${DateTime.now().millisecondsSinceEpoch}.png';
final file = File(path);
await file.writeAsBytes(bytes);
await Share.shareXFiles([XFile(path)], text: '新春祝福卡片');
}
@override
Widget build(BuildContext context) {
final data = widget.card;
return Scaffold(
backgroundColor: const Color(0xFFFFF8F0),
appBar: AppBar(
title: const Text('祝福卡片'),
backgroundColor: const Color(0xFFC41E3A),
foregroundColor: Colors.white,
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: Screenshot(
controller: _screenshotController,
child: _buildCard(data),
),
),
),
),
BlessingStickerPalette(
onAddSticker: _addSticker,
canAdd: (type) => _stickers.every((e) => e.type != type),
),
if (_stickers.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'拖动贴纸可调整位置',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _captureAndShare,
icon: const Icon(Icons.share),
label: const Text('截图分享'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC41E3A),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
),
],
),
),
);
}
Widget _buildDraggableSticker(int index, StickerItem item) {
return Positioned(
left: item.rect.left,
top: item.rect.top,
width: item.rect.width,
height: item.rect.height,
child: GestureDetector(
onPanUpdate: (details) => _updateStickerPosition(index, details.delta),
behavior: HitTestBehavior.opaque,
child: Center(child: item.child),
),
);
}
Widget _buildCard(BlessingCardData data) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: _cardWidth,
height: _cardHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 12,
offset: const Offset(0, 4),
),
],
gradient: LinearGradient(
begin: data.gradientBegin,
end: data.gradientEnd,
colors: data.gradientColors,
),
),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
data.text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 17,
height: 1.55,
color: Color(0xFF5C4033),
fontWeight: FontWeight.w500,
),
),
),
),
if (_stickers.isNotEmpty)
..._stickers.asMap().entries.map(
(entry) => _buildDraggableSticker(entry.key, entry.value),
),
],
),
),
);
}
}
/// 贴纸项(与 blessing_page_stickers 中 StickerItem 对应,此处独立避免循环依赖)
class StickerItem {
final StickerType type;
final Widget child;
final Rect rect;
StickerItem({required this.type, required this.child, required this.rect});
}

说明 :
BlessingStickerPalette、StickerType、stickerTypeLabel、buildStickerWidget由blessing_page_stickers.dart提供,需保证该文件存在并正确 export(见项目lib/pages/blessing_page_stickers.dart)。列表页通过Navigator.push(context, MaterialPageRoute(builder: (_) => BlessingDetailPage(card: card)))传入BlessingCardData即可打开此详情页。
四、高级封装
4.1 为什么要封装
- 数据与 UI 分离 :
BlessingCardData、BlessingCategory、BlessingCardPresets单独成文件,祝福语与分类增删改只动数据,列表/详情只消费数据。 - 贴纸组件化 :贴纸类型、贴纸
Widget、贴纸选择栏(BlessingStickerPalette)集中在blessing_page_stickers.dart,详情页只负责「添加/拖动/截图」,贴纸样式扩展在一处完成。 - 列表与详情拆分:列表页只做分类展示与跳转,详情页只做单卡编辑与分享,职责清晰,便于维护与多端一致。
4.2 封装结构

- 数据层 :
blessing_card_data.dart,对外暴露BlessingCardPresets.categories。 - 列表页 :
blessing_page.dart,按分类build区块,点击卡片push详情并传入BlessingCardData。 - 详情页 :
blessing_detail_page.dart,9:16卡片、贴纸槽位与拖动、Screenshot+ 截图分享。 - 贴纸层 :
blessing_page_stickers.dart,贴纸类型、各贴纸Widget、选择栏BlessingStickerPalette。
4.3 可复用封装代码与文件清单
| 文件 | 说明 |
|---|---|
lib/pages/blessing_card_data.dart |
祝福卡片数据与分类(BlessingCardData、BlessingCategory、Presets) |
lib/pages/blessing_page.dart |
春节祝福列表页(分类 + 卡片列表,点击进详情) |
lib/pages/blessing_detail_page.dart |
详情页:9:16 卡片、贴纸槽位与拖动、截图分享 |
lib/pages/blessing_page_stickers.dart |
贴纸类型、贴纸组件、贴纸选择栏BlessingStickerPalette |
五、工程优化
5.1 体积优化
screenshot:只截当前卡片区域,避免对整页或过长内容做高清截图,控制内存与文件大小。share_plus:使用鸿蒙化分支即可,无需额外native插件;鸿蒙 HAP 发布时可使用flutter build hap --release并视需开启混淆。
5.2 性能优化
- 列表页 :使用
ListView.builder按需构建,避免一次性build全部卡片。 - 截图时机 :
capture(delay: ...)保留一定delay,确保卡片与贴纸已绘制完成再截取。 - 贴纸数量:同类型不重复添加、总数为槽位数上限(如 8),避免单卡贴纸过多导致布局过重。
六、高频问题
6.1 集成报错
| 报错/现象 | 原因 | 解决 |
|---|---|---|
Target of URI doesn't exist: share_plus / screenshot |
依赖未解析 | 执行 flutter pub get;必要时重启 IDE 或 Analysis Server |
path_provider版本冲突 |
与share_plus鸿蒙分支依赖冲突 |
不要在pubspec中单独写 path_provider: ^x.x.x,使用依赖链中的版本 |
6.2 API 使用误区
- 分享面板不弹出 :确认使用鸿蒙化
share_plus分支(ref: br_share_plus-v10.1.1_ohos),并检查系统权限/分享能力是否被限制。 - 截图全黑或空白 :适当增大
capture(delay: ...),或确保被截Widget已完成布局且被Screenshot正确包裹。 - 贴纸拖不动 :确认
GestureDetector在Stack内、且onPanUpdate里调用了setState;检查是否被父级手势或ScrollView消费。
6.3 性能问题
- 若详情页打开或拖动贴纸时卡顿:检查是否在
build里做了重计算;贴纸列表不宜过长,同卡同类型仅一张。 - 若分享文件过大:将
pixelRatio控制在 2.0~4.0 之间,避免过高分辨率。