Files
xianyan/lib/editor/services/export/export_service.dart
Developer b11d53ca58 feat: 新增画布样式后处理功能及导出一致性优化
refactor(editor): 将画布样式相关功能移至独立文件
fix(editor): 修复描边坐标偏移问题
feat(export): 新增applyCanvasStyle后处理方法
feat(settings): 实现CanvasStyleModel持久化存储
fix(feed): 互动操作增加登录检查避免401错误
feat(wallpaper): 实现无限下拉加载功能
fix(text): 修复富文本编辑器初始化问题
2026-05-05 08:24:28 +08:00

469 lines
14 KiB
Dart

// ============================================================
// 闲言APP — 导出服务
// 创建时间: 2026-04-20
// 更新时间: 2026-05-05
// 作用: 截图导出 + 保存相册 + 系统分享 + WidgetLayer合成导出 + 压缩导出
// 上次更新: 新增 applyCanvasStyle 画布样式后处理(边框+阴影+叠层+圆角)
// ============================================================
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:pro_image_editor/pro_image_editor.dart' as pro;
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/core/utils/platform_utils.dart' as pu;
import 'package:xianyan/editor/services/export/export_io_native.dart'
if (dart.library.html) 'export_io_web.dart';
import 'package:xianyan/editor/services/export/image_compress_service.dart';
import 'package:xianyan/editor/services/image/widget_layer_renderer.dart';
import 'package:pro_image_editor/core/models/canvas_style_model.dart';
class ExportService {
ExportService._();
static bool get isSaveToGallerySupported => !kIsWeb && pu.supportsFilesystem;
static bool get isShareSupported => !kIsWeb;
static bool get isFileExportSupported => !kIsWeb && pu.supportsFilesystem;
/// 从 RepaintBoundary 截图并保存到相册
static Future<bool> saveToGallery(
GlobalKey repaintKey, {
double pixelRatio = 3.0,
}) async {
if (!isSaveToGallerySupported) {
Log.w('saveToGallery: 当前平台不支持保存到相册');
return false;
}
try {
final renderObj = repaintKey.currentContext?.findRenderObject();
if (renderObj == null || renderObj is! RenderRepaintBoundary) {
Log.e('RenderObject 不是 RepaintBoundary 类型');
return false;
}
final boundary = renderObj;
final image = await boundary.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
Log.e('图片转换失败');
return false;
}
return saveToGalleryImpl(byteData.buffer.asUint8List());
} catch (e) {
Log.e('保存到相册失败', e);
return false;
}
}
/// 从 RepaintBoundary 截图并分享
static Future<void> shareImage(
GlobalKey repaintKey, {
double pixelRatio = 3.0,
}) async {
if (!isShareSupported) {
Log.w('shareImage: 当前平台不支持分享');
return;
}
try {
final boundary =
repaintKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return;
final image = await boundary.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return;
await shareFileImpl(byteData.buffer.asUint8List());
} catch (e) {
Log.e('分享失败', e);
}
}
/// 高分辨率导出 (返回 PNG bytes)
static Future<Uint8List?> exportHighRes(
GlobalKey repaintKey, {
double pixelRatio = 3.0,
}) async {
try {
final boundary =
repaintKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return null;
final image = await boundary.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return null;
return byteData.buffer.asUint8List();
} catch (e) {
Log.e('高分辨率导出失败', e);
return null;
}
}
/// 直接从 bytes 保存到相册 (pro_image_editor 导出适配)
static Future<bool> saveBytesToGallery(Uint8List bytes) async {
if (!isSaveToGallerySupported) {
Log.w('saveBytesToGallery: 当前平台不支持保存到相册');
return false;
}
try {
return saveToGalleryImpl(bytes);
} catch (e) {
Log.e('保存到相册失败', e);
return false;
}
}
/// 直接从 bytes 分享 (pro_image_editor 导出适配)
static Future<void> shareBytes(Uint8List bytes) async {
if (!isShareSupported) {
Log.w('shareBytes: 当前平台不支持分享');
return;
}
try {
await shareFileImpl(bytes);
} catch (e) {
Log.e('分享失败', e);
}
}
/// 合成导出 — 先预渲染WidgetLayer再合成最终图片
static Future<Uint8List?> exportWithWidgetLayers({
required Uint8List baseBytes,
required List<pro.WidgetLayer> widgetLayers,
required Size canvasSize,
double pixelRatio = 3.0,
}) async {
try {
final baseImage = await decodeImageFromList(baseBytes);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder)
..drawImage(baseImage, Offset.zero, Paint());
for (final layer in widgetLayers) {
final layerW = (layer.scale * 200).clamp(50.0, 2000.0);
final layerH = (layer.scale * 200).clamp(50.0, 2000.0);
final layerSize = Size(layerW, layerH);
final rendered = await WidgetLayerRenderer.renderLayer(
layer: layer,
layerSize: layerSize,
pixelRatio: pixelRatio,
);
if (rendered == null) continue;
final layerImage = await decodeImageFromList(rendered);
final dx = layer.offset.dx * baseImage.width - layerImage.width / 2;
final dy = layer.offset.dy * baseImage.height - layerImage.height / 2;
canvas.save();
canvas.translate(dx + layerImage.width / 2, dy + layerImage.height / 2);
canvas.rotate(layer.rotation);
canvas.drawImage(
layerImage,
Offset(-layerImage.width / 2, -layerImage.height / 2),
Paint(),
);
canvas.restore();
layerImage.dispose();
}
baseImage.dispose();
final picture = recorder.endRecording();
final finalImage = await picture.toImage(
baseImage.width,
baseImage.height,
);
final byteData = await finalImage.toByteData(
format: ui.ImageByteFormat.png,
);
finalImage.dispose();
if (byteData == null) return null;
return byteData.buffer.asUint8List();
} catch (e) {
Log.e('合成导出失败', e);
return null;
}
}
/// 压缩导出并保存到相册
static Future<bool> saveCompressedToGallery(
Uint8List pngBytes, {
int quality = 90,
CompressFormat format = CompressFormat.jpeg,
}) async {
if (!isSaveToGallerySupported) {
Log.w('saveCompressedToGallery: 当前平台不支持保存到相册');
return false;
}
try {
final compressed = await ImageCompressService.compressForExport(
pngBytes,
quality: quality,
format: format,
);
if (compressed == null) {
return saveBytesToGallery(pngBytes);
}
return saveToGalleryImpl(compressed);
} catch (e) {
Log.e('压缩保存到相册失败', e);
return saveBytesToGallery(pngBytes);
}
}
/// 压缩导出并分享
static Future<void> shareCompressed(
Uint8List pngBytes, {
int quality = 90,
CompressFormat format = CompressFormat.jpeg,
}) async {
if (!isShareSupported) {
Log.w('shareCompressed: 当前平台不支持分享');
return;
}
try {
final compressed = await ImageCompressService.compressForExport(
pngBytes,
quality: quality,
format: format,
);
final bytesToShare = compressed ?? pngBytes;
final ext = ImageCompressService.formatExt(format);
await shareCompressedFileImpl(bytesToShare, ext: ext);
} catch (e) {
Log.e('压缩分享失败', e);
await shareBytes(pngBytes);
}
}
static Future<Uint8List?> applyRoundedCorners(
Uint8List imageBytes, {
double radius = 20.0,
}) async {
try {
final codec = await ui.instantiateImageCodec(imageBytes);
final frame = await codec.getNextFrame();
final image = frame.image;
final width = image.width;
final height = image.height;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
Radius.circular(radius * (width / 1080)),
);
canvas.clipRRect(rrect);
canvas.drawImage(image, Offset.zero, Paint());
final picture = recorder.endRecording();
final roundedImage = await picture.toImage(width, height);
final byteData = await roundedImage.toByteData(
format: ui.ImageByteFormat.png,
);
image.dispose();
roundedImage.dispose();
if (byteData == null) return null;
return byteData.buffer.asUint8List();
} catch (e) {
Log.e('圆角处理失败', e);
return imageBytes;
}
}
static Future<Uint8List?> applyCanvasStyle(
Uint8List imageBytes,
CanvasStyleModel style,
) async {
try {
final codec = await ui.instantiateImageCodec(imageBytes);
final frame = await codec.getNextFrame();
final srcImage = frame.image;
final srcW = srcImage.width.toDouble();
final srcH = srcImage.height.toDouble();
final scale = srcW / 1080.0;
final radius = style.borderRadius * scale;
final borderWidth = style.borderWidth * scale;
final shadowBlur = style.shadowBlur * scale;
final shadowSpread = style.shadowSpread * scale;
final shadowOffsetX = style.shadowOffsetX * scale;
final shadowOffsetY =
(style.shadowOffsetY != 0
? style.shadowOffsetY
: style.shadowBlur * 0.15) *
scale;
final stackDist = style.stackDistance * scale;
final hasBorder = style.hasBorder;
final hasShadow = style.hasShadow;
final hasStack = style.hasStack;
final hasMargin = style.hasOuterMargin;
if (!hasBorder && !hasShadow && !hasStack && radius <= 0 && !hasMargin) {
srcImage.dispose();
return imageBytes;
}
final autoPadding = hasShadow
? shadowBlur + shadowSpread + 8 * scale
: 0.0;
final margin = hasMargin ? style.outerMargin * scale : autoPadding;
final stackExtra = hasStack ? stackDist * style.stackLayers : 0.0;
double extraLeft = 0, extraTop = 0, extraRight = 0, extraBottom = 0;
if (hasStack) {
final pos = style.stackPosition;
if (pos == CanvasStackPosition.left) extraLeft = stackExtra;
if (pos == CanvasStackPosition.right) extraRight = stackExtra;
if (pos == CanvasStackPosition.top) extraTop = stackExtra;
if (pos == CanvasStackPosition.bottom) extraBottom = stackExtra;
}
final totalW = srcW + margin * 2 + extraLeft + extraRight;
final totalH = srcH + margin * 2 + extraTop + extraBottom;
final canvasW = totalW.toInt();
final canvasH = totalH.toInt();
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
if (hasMargin) {
canvas.clipRect(
Rect.fromLTWH(0, 0, canvasW.toDouble(), canvasH.toDouble()),
);
}
final imgX = margin + extraLeft;
final imgY = margin + extraTop;
final imgRect = Rect.fromLTWH(imgX, imgY, srcW, srcH);
final imgRRect = RRect.fromRectAndRadius(
imgRect,
Radius.circular(radius),
);
if (hasStack) {
for (int i = style.stackLayers; i >= 1; i--) {
double dx = 0, dy = 0;
final offset = stackDist * i;
if (style.stackPosition == CanvasStackPosition.left) dx = -offset;
if (style.stackPosition == CanvasStackPosition.right) dx = offset;
if (style.stackPosition == CanvasStackPosition.top) dy = -offset;
if (style.stackPosition == CanvasStackPosition.bottom) dy = offset;
final layerRect = imgRect.translate(dx, dy);
final layerRRect = RRect.fromRectAndRadius(
layerRect,
Radius.circular(radius),
);
canvas.drawRRect(
layerRRect,
Paint()..color = const Color(0x99FFFFFF),
);
if (hasBorder) {
canvas.drawRRect(
layerRRect,
Paint()
..color = style.borderColor.withValues(alpha: 0.2)
..style = PaintingStyle.stroke
..strokeWidth = borderWidth * 0.5,
);
}
}
}
if (hasShadow) {
final shadowRRect = imgRRect
.shift(Offset(shadowOffsetX, shadowOffsetY))
.inflate(shadowSpread);
canvas.drawRRect(
shadowRRect,
Paint()
..color = Colors.black.withValues(alpha: style.shadowOpacity)
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur),
);
}
canvas.save();
canvas.clipRRect(imgRRect);
canvas.drawImage(srcImage, Offset(imgX, imgY), Paint());
canvas.restore();
if (hasBorder) {
if (style.borderStyle == CanvasBorderStyle.dashed) {
_drawDashedRRect(canvas, imgRRect, style.borderColor, borderWidth);
} else {
canvas.drawRRect(
imgRRect,
Paint()
..color = style.borderColor
..style = PaintingStyle.stroke
..strokeWidth = borderWidth,
);
}
}
final picture = recorder.endRecording();
final resultImage = await picture.toImage(canvasW, canvasH);
final byteData = await resultImage.toByteData(
format: ui.ImageByteFormat.png,
);
srcImage.dispose();
resultImage.dispose();
if (byteData == null) return null;
return byteData.buffer.asUint8List();
} catch (e) {
Log.e('画布样式处理失败', e);
return imageBytes;
}
}
static void _drawDashedRRect(
Canvas canvas,
RRect rrect,
Color color,
double strokeWidth,
) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
const dashWidth = 8.0;
const dashGap = 5.0;
final path = Path()..addRRect(rrect);
final metrics = path.computeMetrics();
for (final metric in metrics) {
double distance = 0;
while (distance < metric.length) {
final end = (distance + dashWidth).clamp(0.0, metric.length);
canvas.drawPath(metric.extractPath(distance, end), paint);
distance += dashWidth + dashGap;
}
}
}
}