用 Flutter 打造跨平台液态玻璃下载器:双引擎架构与工程化实践

做下载器这件事,看起来是个"老轮子"——但真正动手你会发现,要把"磁力、直链、短视频页、需要登录态的资源"都接住,并且做成一个安装即用、界面还能打的桌面应用,工程量一点都不小。
本文以一个真实项目 Lumen(液态玻璃风格的 macOS/Windows 下载管理器)为例,完整复盘它的架构设计与几个关键技术点的落地方式。所有代码片段都使用真实存在的 API,可直接参考改造。
背景:一个引擎不够用
最初的内核只用了一个纯 HTTP 下载器(代号 Surge,Go 写的自包含二进制,稳、轻)。但它有两个硬伤:
- 不支持 BitTorrent / 磁力;
- 不支持自定义请求头(只有
-o指定输出名)。
而很多场景恰恰需要:磁力要 BT 引擎,防盗链资源要带 Referer,需要登录态的站点要带 Cookie 和特定 User-Agent。
结论很自然:单引擎搞不定,上双引擎。系统里现成的 aria2c 同时支持 BT、HTTP/HTTPS、Metalink,并且原生支持 user-agent、header、referer 选项。于是架构变成:
Surge 负责"普通直链下载"(稳定、省心),aria2 负责"磁力/种子、以及一切需要自定义请求头的 HTTP 下载"。
整体架构:一张图看懂
┌─────────────────────────────────────────────┐
│ Flutter UI │
│ 液态玻璃组件 / 列表 / 设置 / 解析面板 │
└───────────────┬─────────────────────────────┘
│ Provider 状态
┌───────────────▼─────────────────────────────┐
│ DownloadController │
│ 自适应轮询 · 全局统计 · 筛选 · 排序 │
└───────────────┬─────────────────────────────┘
│
┌───────────────▼─────────────────────────────┐
│ DownloadRepository │
│ LinkClassifier 按链接类型路由 │
└──────┬───────────────────────────┬──────────┘
│ │
┌──────────▼─────────┐ ┌──────────▼──────────┐
│ Surge 引擎 │ │ aria2 引擎 │
│ HTTP /list 读 │ │ JSON-RPC 2.0 │
│ CLI 写 │ │ token:secret 鉴权 │
└────────────────────┘ └─────────────────────┘
外部入口:浏览器扩展(MV3 下载接管) ──HTTP──▶ 本地回环桥 /add
下面挑几个最有价值的点展开。
核心一:链接智能分流
双引擎的关键不是"有两个引擎",而是"一个链接进来,怎么自动决定走哪条路"。我用一个纯函数分类器解决,优先级是设计的重点:
enum LinkRoute { torrent, directFile, share, generic }
class LinkClassifier {
// 磁力 / 种子
static final _torrent =
RegExp(r'^magnet:|\.torrent($|\?)', caseSensitive: false);
// 明确的直链文件(含流媒体清单后缀)
static final _directFile = RegExp(
r'\.(mp4|mkv|mov|mp3|flac|zip|rar|7z|pkg|dmg|exe|apk|iso|m3u8|mpd)($|\?)',
caseSensitive: false,
);
// 受支持的短视频/图文平台"页面"
static final _share = RegExp(
r'(douyin|kuaishou|bilibili|xiaohongshu|weibo)\.com',
caseSensitive: false,
);
static LinkRoute classify(String url) {
if (_torrent.hasMatch(url)) return LinkRoute.torrent;
// 关键:directFile 必须优先于 share
if (_directFile.hasMatch(url)) return LinkRoute.directFile;
if (_share.hasMatch(url)) return LinkRoute.share;
return LinkRoute.generic;
}
}
这里踩过一个坑:https://v.douyin.com/xxx.mp4 这种链接,既匹配平台域名又是直链后缀。如果 share 判定在前,它会被错误地丢去"解析页面",而实际上它就是个 mp4 直链。所以 directFile 的优先级必须高于 share——分类器里顺序即语义。
路由层据此决定引擎:
Future<AddResult> add(String url) {
switch (LinkClassifier.classify(url)) {
case LinkRoute.torrent:
return _aria2.addUri(url); // 磁力/种子 → aria2
case LinkRoute.directFile:
case LinkRoute.generic:
return _surge.add(url); // 直链 → surge
case LinkRoute.share:
return _parser.parseThenDownload(url); // 平台页 → 解析后再下
}
}
核心二:混合数据层与 aria2 JSON-RPC
Surge 这边采用"读写分离":读用它的 HTTP /list(字段丰富,能拿到进度、速度、状态),写用 CLI(更稳)。aria2 这边则是标准的 JSON-RPC 2.0。下面是一个可直接用的 aria2 客户端最小实现:
import 'dart:convert';
import 'package:http/http.dart' as http;
class Aria2Rpc {
Aria2Rpc(this.endpoint, this.secret);
final String endpoint; // 例:http://127.0.0.1:6810/jsonrpc
final String secret;
Future<dynamic> _call(String method, List<dynamic> params) async {
final payload = {
'jsonrpc': '2.0',
'id': DateTime.now().microsecondsSinceEpoch.toString(),
'method': method,
// 所有方法第一个参数都是 token:<secret> 鉴权
'params': ['token:$secret', ...params],
};
final res = await http.post(
Uri.parse(endpoint),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
);
final json = jsonDecode(res.body) as Map<String, dynamic>;
if (json['error'] != null) {
throw StateError('aria2 error: ${json['error']}');
}
return json['result'];
}
/// 返回 gid(任务句柄)
Future<String> addUri(String uri, {Map<String, dynamic>? options}) async {
final r = await _call('aria2.addUri', [
[uri],
options ?? <String, dynamic>{},
]);
return r as String;
}
Future<Map<String, dynamic>> tellStatus(String gid) async {
final r = await _call('aria2.tellStatus', [gid]);
return Map<String, dynamic>.from(r as Map);
}
}
自适应轮询:省电的小心机
桌面下载器没必要一直高频刷新。一个简单但有效的策略是:有活跃任务时高频,空闲时降频。
Duration _pollInterval() =>
_hasActiveTask ? const Duration(milliseconds: 700)
: const Duration(seconds: 2);
void _scheduleNext() {
_timer = Timer(_pollInterval(), () async {
await _refresh();
_scheduleNext();
});
}
进一步还能用 aria2 的 aria2.tellActive 配合事件,或上 SSE,把轮询彻底干掉——但对桌面端而言,自适应轮询的收益/复杂度比已经很划算。
核心三:自定义 UA / 请求头怎么落地
前面说过 Surge CLI 不支持自定义头。那带 UA/头的下载怎么办?自动改走 aria2——这正是双引擎的红利。aria2 的 addUri 选项天然支持:
Future<String> addWithHeaders(
String url, {
String? userAgent,
String? referer,
Map<String, String> headers = const {},
}) {
final options = <String, dynamic>{
if (userAgent != null) 'user-agent': userAgent,
if (referer != null) 'referer': referer,
// aria2 的 header 选项可重复,RPC 里用字符串数组传多条
if (headers.isNotEmpty)
'header': headers.entries.map((e) => '${e.key}: ${e.value}').toList(),
};
return _aria2.addUri(url, options: options);
}
产品上做成两层:
| 层级 | 位置 | 作用范围 |
|---|---|---|
| 全局默认 | 设置页(含一键填入 Chrome UA) | 自动套用到所有下载 |
| 逐次覆盖 | 下载栏「⚙️ 高级」 | 仅本次下载,叠加在全局之上 |
合并逻辑就是"全局默认头 ← 本次覆盖头",一个 Map 合并搞定,既省心又灵活。
核心四:引擎内置,做到"下载即用"
让普通用户去 brew install aria2 是劝退的。目标是:把引擎打进 .app,首次启动自解压,永不依赖系统环境。
难点在 macOS 的动态库:Homebrew 装的 aria2c 依赖一串 dylib(openssl、libssh2、c-ares、gettext、sqlite……),它们的加载路径写死成 /opt/homebrew/...。直接拷出来到别的机器必然加载失败。解决办法是把依赖闭包一起带上,并用 install_name_tool 把加载路径改写成相对当前目录的 @loader_path,最后 ad-hoc 重签:
BIN=native/macos/bin
DEPS=(libssl.3.dylib libcrypto.3.dylib libssh2.1.dylib \
libcares.2.dylib libintl.8.dylib libsqlite3.dylib)
for dep in "${DEPS[@]}"; do
# 找到 aria2c 里写死的绝对路径并改成 @loader_path/<dep>
old=$(otool -L "$BIN/aria2c" | awk '/'"$dep"'/{print $1}')
[ -n "$old" ] && install_name_tool -change "$old" "@loader_path/$dep" "$BIN/aria2c"
done
# 改过 Mach-O 头之后必须重新签名,否则 Gatekeeper 拒绝加载
codesign --force --sign - "$BIN/aria2c"
运行时再把这批二进制从 Flutter assets 解压到可写目录并赋可执行权限:
Future<String> ensureBinary(String name) async {
final dir = await getApplicationSupportDirectory(); // ~/Library/Application Support/Lumen
final out = File('${dir.path}/bin/$name');
if (!out.existsSync()) {
final bytes = await rootBundle.load('native/macos/bin/$name');
await out.create(recursive: true);
await out.writeAsBytes(bytes.buffer.asUint8List());
await Process.run('chmod', ['+x', out.path]); // 关键:加可执行位
}
return out.path;
}
校验方法:把整包 relocate 到
/tmp、临时禁用 Homebrew 的 fallback 路径再跑一遍,能正常下载才算真自包含。
核心五:浏览器下载接管(IDM 同款)
想做到"在网页点任意下载,自动交给应用",MV3 的正确姿势是监听 chrome.downloads.onCreated,取消浏览器下载并转交。这里有几条铁律:防自环、应用不可达时绝不吞文件。
chrome.downloads.onCreated.addListener(async (item) => {
// 1) 防自环:应用回落用扩展发起的下载不再接管
if (item.byExtensionId === chrome.runtime.id) return;
const cfg = await chrome.storage.sync.get({ intercept: true, port: 8765 });
if (!cfg.intercept) return;
// 2) 应用不在线就别接管,把下载交回浏览器(绝不吞文件)
if (!(await isAppOnline())) return;
await chrome.downloads.cancel(item.id);
await fetch(`http://127.0.0.1:${cfg.port}/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: item.finalUrl || item.url,
filename: item.filename,
referrer: item.referrer, // 带上 Referer,绕过防盗链
}),
});
});
isAppOnline() 这里有个性能细节:下载是会"爆发"的(一个页面可能瞬间触发多个),如果每个都同步去 ping 一次应用,会卡。做法是缓存最近一次 ping 结果:
let appOnline = false, lastPingAt = 0;
const PING_TTL_MS = 2500;
async function isAppOnline() {
if (Date.now() - lastPingAt < PING_TTL_MS) return appOnline; // 复用近期结果
return pingApp();
}
应用侧则跑一个本地回环桥(只听 127.0.0.1),提供 /ping 与 /add,带 CORS 和端口降级(占用就换下一个),收到链接后复用前面的分类器决定走哪个引擎。
核心六:下载前重复检测
"同一个文件下了好几份"是下载器的经典痛点。根因是:很多下载器遇到同名文件会自动改名成 name(1).ext 落地,于是重复添加会静默堆副本。
解决思路是在真正下载前用 HEAD 探测远端文件特征(不拉响应体),再与目标目录比对:
Future<RemoteMeta> probe(String url) async {
final res = await http.head(Uri.parse(url)); // 跟随重定向,不下载 body
final len = int.tryParse(res.headers['content-length'] ?? '');
// Content-Disposition: attachment; filename*=UTF-8''xxx.zip
final name = _parseFilename(res.headers['content-disposition']) ??
Uri.parse(url).pathSegments.last;
return RemoteMeta(name: name, size: len);
}
然后给出三种判定,交给用户决策(默认取消,避免误操作):
| 判定 | 含义 | 建议动作 |
|---|---|---|
identical |
同名同大小 | 多半重复,默认取消 |
sameNameDiffSize |
同名不同大小 | 很可能是版本更新/不同文件 |
sameSizeDiffName |
同大小不同名 | 疑似改名副本 |
只对单文件 HTTP(S) 直链做检测;磁力/种子/HLS/解析页一律跳过——它们没有可靠的"单文件特征"。
界面:液态玻璃是怎么做出来的
桌面端要有"真·毛玻璃",光靠 BackdropFilter 不够,还要让窗口本身支持模糊。flutter_acrylic + window_manager 负责窗口级效果:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Window.initialize();
await windowManager.ensureInitialized();
// 窗口级 acrylic/vibrancy,内容才能"透出"背景
await Window.setEffect(effect: WindowEffect.acrylic, dark: true);
runApp(const LumenApp());
}
组件级再叠一层"液态玻璃":强模糊 + 半透明体 + 边缘高光(rim light)+ 顶部高光(sheen),让面板有"浮起来"的立体感。
class GlassPanel extends StatelessWidget {
const GlassPanel({super.key, required this.child, this.blur = 26});
final Widget child;
final double blur;
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06), // 玻璃体
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)), // rim
),
child: child,
),
),
);
}
}
实战经验:亮色主题最难。暖色"纸感"试过被嫌"太黄",最后定的是"冷调现代白"——背景用略深的冷浅灰蓝(比面板还深一点点,玻璃才浮得起来),面板用半实心强模糊保证文字可读性。可读性永远优先于炫技。
工程化:让 CI 只在该编译时才编译
这部分是我认为最值得分享的——不是"能跑就行",而是"该跑才跑"。
需求很具体:
- 改了 app 源码(Dart/原生/平台/依赖)→ 自动编译 mac+win 资产;
- 改了浏览器扩展(纯 JS/HTML)→ 只重新打包扩展,绝不重编 app;
- 打
v*tag → 出正式版本 release。
GitHub Actions 的 路径过滤(paths filter)+ 可复用工作流(workflow_call) 正好对症。先把"构建逻辑"抽成可复用工作流:
# .github/workflows/build.yml —— 可复用:被其他工作流调用
on:
workflow_call:
inputs:
release_tag: { required: true, type: string }
permissions:
contents: write
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: flutter build macos --release
# ...打 dmg,然后 gh release upload 到 inputs.release_tag
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: flutter build windows --release
# ...打 zip + Inno Setup 安装器,再上传
再用 路径过滤 决定"什么改动才触发 app 编译"——注意 browser_extension/** 故意不在列表里:
# .github/workflows/nightly.yml —— 只有 app 源码变更才编译
on:
push:
branches: [main]
paths:
- "lib/**"
- "native/**"
- "macos/**"
- "windows/**"
- "pubspec.yaml"
# 注意:browser_extension/**、docs/**、*.md 都不在此 → 它们绝不触发 app 重编
workflow_dispatch:
permissions:
contents: write
jobs:
assets:
uses: ./.github/workflows/build.yml # 复用上面的构建
with: { release_tag: nightly }
扩展则走自己的工作流,触发条件互斥,根本不碰 Flutter 构建:
# .github/workflows/extension.yml —— 只在扩展变更时打包,不编译 app
on:
push:
branches: [main]
paths: ["browser_extension/**"]
workflow_dispatch:
permissions:
contents: write
jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd browser_extension && zip -r ../Lumen-Extension.zip .
- env: { GH_TOKEN: "${{ github.token }}" }
run: gh release upload extension-latest Lumen-Extension.zip --clobber
这样就实现了"软件改动才编译资产,插件改动只打包插件"。验证也很直观:只改 .github/workflows/ 推上去,不会触发任何 app 构建——说明路径过滤生效。
小贴士:
paths过滤会同时作用于 tag 推送,且 tag 的"变更集"判定并不可靠。所以把"打 tag 出正式版"单独放一个不带paths的release.yml,保证 tag 一定触发发布。
踩坑记录(都是真金白银)
- Inno Setup 找不到产物:
installer.iss里相对路径默认相对脚本所在目录(windows/),而构建产物在仓库根。加一行SourceDir=..让相对路径以仓库根为基准即可。 - “把文件复制到自己”:加了"兜底复制安装包到 dist/"的步骤,结果
SourceDir=..已经直接输出到了dist/,于是Copy-Item报Cannot overwrite item with itself。修法是只在源路径 ≠ 目标路径时才复制。 mapEquals找不到:Flutter 的material.dart的show列表不导出它,需要显式import 'package:flutter/foundation.dart'。- macOS 改了二进制不重签:
install_name_tool改过 Mach-O 后不codesign重签,加载直接被系统拒。
小结
把这些点串起来,这个项目其实回答了"做一个现代桌面下载器"的几个核心命题:
- 架构:用链接分类器把"协议差异"收敛成一个枚举,双引擎各司其职;
- 数据:读写分离 + 自适应轮询,稳与省电兼顾;
- 体验:引擎内置免安装、浏览器接管、重复检测,把"能用"做成"好用";
- 工程:用路径过滤 + 可复用工作流,让 CI 聪明到"只在该编译时编译"。
技术选型上,Flutter 负责跨平台 UI 与编排,真正的"重活"(下载、BT、自定义头)交给成熟的原生二进制——Flutter 调用原生能力,而不是什么都自己造,往往是桌面端最务实的路线。
开源地址:https://github.com/MageGojo/Lumen
更多推荐



所有评论(0)