在这里插入图片描述

做下载器这件事,看起来是个"老轮子"——但真正动手你会发现,要把"磁力、直链、短视频页、需要登录态的资源"都接住,并且做成一个安装即用、界面还能打的桌面应用,工程量一点都不小。

本文以一个真实项目 Lumen(液态玻璃风格的 macOS/Windows 下载管理器)为例,完整复盘它的架构设计与几个关键技术点的落地方式。所有代码片段都使用真实存在的 API,可直接参考改造。

背景:一个引擎不够用

最初的内核只用了一个纯 HTTP 下载器(代号 Surge,Go 写的自包含二进制,稳、轻)。但它有两个硬伤:

  1. 不支持 BitTorrent / 磁力;
  2. 不支持自定义请求头(只有 -o 指定输出名)。

而很多场景恰恰需要:磁力要 BT 引擎,防盗链资源要带 Referer,需要登录态的站点要带 Cookie 和特定 User-Agent

结论很自然:单引擎搞不定,上双引擎。系统里现成的 aria2c 同时支持 BT、HTTP/HTTPS、Metalink,并且原生支持 user-agentheaderreferer 选项。于是架构变成:

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 出正式版"单独放一个不带 pathsrelease.yml,保证 tag 一定触发发布。

踩坑记录(都是真金白银)

  • Inno Setup 找不到产物:installer.iss 里相对路径默认相对脚本所在目录(windows/),而构建产物在仓库根。加一行 SourceDir=.. 让相对路径以仓库根为基准即可。
  • “把文件复制到自己”:加了"兜底复制安装包到 dist/"的步骤,结果 SourceDir=.. 已经直接输出到了 dist/,于是 Copy-ItemCannot overwrite item with itself。修法是只在源路径 ≠ 目标路径时才复制。
  • mapEquals 找不到:Flutter 的 material.dartshow 列表不导出它,需要显式 import 'package:flutter/foundation.dart'
  • macOS 改了二进制不重签:install_name_tool 改过 Mach-O 后不 codesign 重签,加载直接被系统拒。

小结

把这些点串起来,这个项目其实回答了"做一个现代桌面下载器"的几个核心命题:

  1. 架构:用链接分类器把"协议差异"收敛成一个枚举,双引擎各司其职;
  2. 数据:读写分离 + 自适应轮询,稳与省电兼顾;
  3. 体验:引擎内置免安装、浏览器接管、重复检测,把"能用"做成"好用";
  4. 工程:用路径过滤 + 可复用工作流,让 CI 聪明到"只在该编译时编译"。

技术选型上,Flutter 负责跨平台 UI 与编排,真正的"重活"(下载、BT、自定义头)交给成熟的原生二进制——Flutter 调用原生能力,而不是什么都自己造,往往是桌面端最务实的路线。

开源地址:https://github.com/MageGojo/Lumen

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐