架构总览
心智模型、完整生命周期、设计决策。
GD 是信号驱动的 actor 模型。每个 Service 拥有私有状态,对外通信仅通过 Qt Signal。UI 层可能随时被替换(桌面 vs Android),但 Service 的行为不应该变。依赖单向:UI → Services → Models ← Features,逆向引用会导致 Service 耦合到特定 UI 框架。
源码:app/services/、app/models/、features/。
组件
| 组件 | 职责 | 源文件 |
|---|---|---|
| CoroutineRunner | Qt↔asyncio 桥接,所有异步工作在它的线程跑 | app/common/coroutine_runner.py |
| TaskService | 任务队列、状态机、调度、持久化 | app/services/task_service.py |
| FeatureService | 功能包加载(拓扑排序)、解析器链、身份预设注入 | app/services/feature_service.py |
| BrowserService | 扩展 WebSocket 通信、配对、任务快照推送 | app/services/browser_service.py |
| SpeedMeter | 全局速度汇总(1 秒/次)、全局速度限制 | app/services/speed_meter.py |
| Aria2RpcServer | Aria2 RPC 协议兼容层 | app/services/aria2_rpc_server.py |
| CategoryService | 按文件类型自动分类下载目录 | app/services/category_service.py |
CoroutineRunner 用独立线程而非主线程内嵌 asyncio,避免大量网络 I/O 阻塞主线程导致 UI 卡顿。任务快照推送每秒做一次全量字符串比较,任务列表小(几十到几百条),增量追踪的代码量是全量比较的十倍,不值得。
一次下载的完整生命周期
从 URL 到任务
无论 URL 从哪来,都经过同一条管道:解析 → 入队 → 调度 → 执行。差异只在入口。
扩展发送下载请求
用户在浏览器里点了下载按钮(或触发了下载接管)。扩展通过 WebSocket 发送请求,携带 URL、请求头、资源元数据。
WebSocket 而非 Native Messaging。跨平台,方便,稳定。Native Messaging 需要每个浏览器安装单独的 host manifest 和平台特定注册,WebSocket 一个端口全通。
桌面端解析
桌面端按优先级遍历所有解析器。第一个能处理这个 URL 的解析器接管,创建一个任务。
如果 URL 命中了身份预设(比如 Bilibili 域名),且请求中没有携带客户端指纹,解析前自动注入预设的指纹配置。
草稿确认或直接入队
两个条件决定是否弹出确认窗口:
- 扩展请求中的
draft参数 - 用户设置中的"下载前先确认"选项
两者都为 false 时直接入队。否则弹出确认窗口,用户选择格式、勾选文件、确认路径后才入队。
确认结果通过 WebSocket 返回扩展:已创建 / 进入草稿 / 解析失败。
入队
执行
任务按步骤顺序逐个执行。一个 Bilibili 视频任务通常有三个步骤:下载视频轨、下载音频轨、FFmpeg 混流。每个步骤独立管理自己的网络连接、进度和错误恢复。
步骤迭代器在每次取下一个步骤前都会重新检查任务状态。如果上一个步骤失败导致任务状态变为失败,迭代立即终止,不会尝试后续步骤。已完成的步骤的输出文件保留在磁盘上。
暂停与恢复
暂停取消运行中的异步任务,所有未完成的步骤标记为已暂停,进度文件保留在磁盘上。
恢复时任务重新入队。已完成的步骤跳过,失败的步骤从头开始(进度清零),已暂停的步骤从进度文件中保存的位置继续。
进程退出时的暂停行为不同:只改状态,不取消异步任务。逐个取消的成本高于直接结束进程,异步任务随进程销毁。
错误与失败
步骤内部的临时错误(网络超时、连接断开)由步骤自己重试(等待 5 秒后重连)。永久错误(HTTP 403/404、致命 I/O 错误)直接中止步骤。
步骤失败后任务标记为失败。用户手动重新开始时,任务重新入队。已完成的步骤跳过,失败的步骤进度清零从头执行。
变更操作
编辑、重新下载、勾选变更、删除都遵循同一个模式:先取消运行,等取消完成后再执行后续动作。
回调的时序取决于任务是否正在运行:运行中 → 异步线程完成清理后回调(下一个事件循环轮次);未运行 → 回调在当前调用栈内立即触发。取消运行中的任务需要等异步线程响应,取消未运行的任务没有异步工作要等。
| 操作 | 取消后做什么 |
|---|---|
| 编辑 | 替换参数(URL、请求头、指纹等),任务 ID 不变 → 重新调度 |
| 重新下载 | 删除输出文件和进度文件 → 所有步骤重置 → 重新调度 |
| 勾选变更 | 更新勾选。如果已完成的任务新增了未完成文件 → 重新入队 |
| 删除 | 可选删除输出文件 → 从内存和持久化中移除 |
多文件任务(BT、播放列表)取消勾选的文件保留已有进度,不删除。如果取消勾选了正在下载的文件,先取消运行再更新勾选。
进程退出与恢复
退出:所有任务设为已暂停 → 写盘 → 逐个停用功能包 → 再次写盘 → 关闭异步线程。
两次写盘:第一次保存暂停状态,第二次捕获功能包停用时可能产生的状态修改。
恢复:从持久化文件加载所有任务。等待中和运行中的任务自动入队调度。失败和已暂停的任务保持原状,等用户手动操作。
持久化
任务列表存储为 JSONL(每行一个 JSON 对象),用类名做反序列化标识。
JSONL 而非 SQLite。任务的子类多且频繁变化,每个子类的字段不同。类名注册表实现了零迁移成本的多态序列化:新增子类不需要写迁移脚本。代价是类名成为序列化契约,改名会导致旧任务无法恢复。
写入策略:200ms 防抖合并多次状态变更为一次写入,先写临时文件再重命名(原子操作)。进程在防抖窗口内崩溃会丢失最近一次状态变更。
改动影响范围
| 你改了 | 检查 |
|---|---|
| 任务调度逻辑 | 快照格式、扩展的任务列表处理 |
| 添加/改解析器 | 优先级数字越小越先检查,确认不和现有冲突 |
| 扩展侧消息格式 | 桌面端消息处理和扩展两边同步 |
| 任务/步骤子类名 | 持久化文件靠类名映射,改名导致旧任务加载失败 |
| 功能包的启用/停用 | 停用是同步阻塞的(用事件循环等待),从异步线程调用会死锁 |
| 功能包配置子类 | 子类在导入时注册到全局配置,标识含类名,同名子类会冲突 |
| 身份预设逻辑 | 解析前注入预设,但请求已携带指纹时跳过注入 |