151 lines
5.9 KiB
Markdown
151 lines
5.9 KiB
Markdown
# 【Demo】分享功能演示(ShareService 五个场景)
|
||
|
||
> 源文件:`lib/pages/demo/share_demo.dart` | 分类:组件封装
|
||
> 一句话:文本分享 / 选图分享 / Widget 截图生成海报分享 / 截图预览 / 保存相册 —— 一个服务类覆盖全部分享场景。
|
||
|
||
---
|
||
|
||
## 一、这个 Demo 解决什么问题
|
||
|
||
"分享"是 App 的标配能力,但场景很多:分享文字、分享图片、**把 UI 截图成海报分享**、保存到相册。
|
||
本页演示 `ShareService`(`lib/core/services/share_service.dart`)的 5 个场景:
|
||
|
||
| # | 场景 | 方法 | 用途 |
|
||
|---|---|---|---|
|
||
| 1 | 分享文本/链接 | `shareText(text:)` | 邀请链接、文案 |
|
||
| 2 | 选图后分享 | `shareImage(filePath:, text:)` | 分享单张图片 |
|
||
| 3 | Widget 截图 → 分享 | `shareFromWidget(context:, text:, builder:)` | **攻克海报分享** |
|
||
| 4 | Widget 截图预览 | `captureWidget(context:, builder:)` | 截图不发,先看效果 |
|
||
| 5a | 选图保存相册 | `saveToGallery(filePath:)` | 存图 |
|
||
| 5b | 截图保存相册 | `saveToGalleryFromBytes(bytes:)` | 海报存图 |
|
||
|
||
> 核心思想:**"分享"是平台能力,不是页面能力** —— 封装成 Service 单例,任何页面一行调用。
|
||
|
||
---
|
||
|
||
## 二、怎么运行 / 演示内容
|
||
|
||
```dart
|
||
runApp(const MaterialApp(home: ShareDemoPage()));
|
||
```
|
||
|
||
- ① 输入框改文案 → 分享文本;
|
||
- ② 选一张图 → 系统分享面板;
|
||
- ③ 点"截图并分享" → 海报 Widget 被截成图片 → 分享面板;
|
||
- ④ 点"仅截图预览" → 弹窗显示截图 + 图片大小(KB);
|
||
- ④ 场景的两个按钮:`选图→保存` / `截图→保存` 写入系统相册。
|
||
|
||
---
|
||
|
||
## 三、核心知识点(新手向)
|
||
|
||
### 1. 场景 3 的核心:Widget → 图片 → 分享
|
||
```dart
|
||
final result = await ShareService.instance.shareFromWidget(
|
||
context: context,
|
||
text: '我攻克了一道难题,快来挑战!',
|
||
builder: (_) => _buildPosterWidget( // 任意 Widget 都能截图
|
||
userName: '张三同学',
|
||
taskName: '二次函数综合练习',
|
||
score: 98,
|
||
),
|
||
);
|
||
```
|
||
海报就是一个**普通 Container**(300 宽、渐变背景、纯 UI 无交互)——不是图片素材,是代码画出来的。
|
||
分享时被 `captureWidget` 截成 PNG 字节,再交给系统分享。
|
||
|
||
### 2. 场景 4:截图预览 = bytes → Image.memory
|
||
```dart
|
||
final bytes = await ShareService.captureWidget(context: context, builder: ...);
|
||
if (bytes == null) { _showResult('截图失败'); return; }
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => Dialog(
|
||
child: Column(children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Image.memory(bytes, width: 280), // 字节流直接渲染
|
||
),
|
||
Text('图片大小:${(bytes.length / 1024).toStringAsFixed(1)} KB'),
|
||
]),
|
||
),
|
||
);
|
||
```
|
||
`captureWidget` 返回 `Uint8List?`——**null 表示截图失败**,必须判空。
|
||
|
||
### 3. 防重入 + 复位:异步按钮的三段式
|
||
```dart
|
||
setState(() => _sharing = true); // ① 锁
|
||
try {
|
||
...await 分享...
|
||
} catch (e) {
|
||
_showResult('分享失败:$e');
|
||
} finally {
|
||
if (mounted) setState(() => _sharing = false); // ③ 无论成败都解锁
|
||
}
|
||
// 按钮:_shareButton(onPressed: _sharing ? null : onPressed) ② 置灰
|
||
```
|
||
`_sharing` 置灰所有分享按钮,防连点重复弹系统面板;`finally` 保证异常也不卡死按钮。
|
||
|
||
### 4. Web 端限制:路径分享不可用
|
||
```dart
|
||
final filePath = picked.first.path;
|
||
if (filePath == null) {
|
||
_showResult('Web 端暂不支持文件路径分享'); // Web 拿不到真实文件路径
|
||
return;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、关键代码剖析
|
||
|
||
```dart
|
||
/// 模拟"攻克海报"布局(实际项目中从业务数据构建)
|
||
Widget _buildPosterWidget({
|
||
required String userName,
|
||
required String taskName,
|
||
required int score,
|
||
}) {
|
||
return Container(
|
||
width: 300,
|
||
padding: const EdgeInsets.all(24),
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient( // 海报背景:紫蓝渐变
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
|
||
),
|
||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||
),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
const Icon(Icons.school, color: Colors.white, size: 40), // Logo
|
||
const Text('爱学蝶变', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||
...
|
||
Text('$score', style: const TextStyle(color: Colors.white, fontSize: 48, fontWeight: FontWeight.w800, height: 1)),
|
||
]),
|
||
);
|
||
}
|
||
```
|
||
海报 Widget 的纪律:**固定宽度 + 纯展示 + 不依赖页面状态**(参数全传入)——这样截图永远稳定。
|
||
|
||
---
|
||
|
||
## 五、踩坑记录
|
||
|
||
1. **captureWidget 必须在页面挂载后调用**(context 有效),否则截图 null;
|
||
2. **bytes 判空**:`if (bytes == null) return;`,别直接 `Image.memory(bytes!)` 崩掉;
|
||
3. Web 端 `picked.first.path` 为 null——图片分享/存相册要走别的通道,代码里已降级提示;
|
||
4. 所有按钮共享 `_sharing` 一把锁:分享中所有分享按钮都禁用,防止并发弹面板;
|
||
5. `finally` 里必须判 `mounted` 再 setState(await 期间页面可能已销毁);
|
||
6. 海报是"代码画的图",字体/图标依赖系统字体,跨设备效果略有差异——正式海报可换用真实图片素材。
|
||
|
||
---
|
||
|
||
## 六、扩展思考
|
||
|
||
- 选图用的 `PickerService.showPickSheet` 与缩略图渲染见 [note_picker_demo.md](note_picker_demo.md);
|
||
- 异步三段式(锁 + try/catch + finally 复位)同样用在 [note_picker_demo.md](note_picker_demo.md) 的上传和 [dio_demo.md](dio_demo.md) 的请求里;
|
||
- 真实项目:"攻克海报"数据来自答题结果接口,把 `_buildPosterWidget` 的参数换成接口返回的 userName/taskName/score 即可;
|
||
- `ShareService` 内部用 share_plus / screenshot / gal 插件——想学封装思想可以直接读它的源码。
|