Skip to content

Update System

lingion edited this page Sep 12, 2026 · 1 revision

应用内更新系统

本文基于 v1.0.54。TL;DR:Sleepy 不接应用市场,更新完全走 GitHub Releases——「关于」页有手动检查按钮,冷启动也会自动查一次;发现新版本后弹 changelog 对话框,APK 下载进 cache 目录,最后用 FileProvider + ACTION_VIEW 把文件交给系统安装器。

路径锚点相对 app/src/main/java/com/lingion/sleepy/,如 util/UpdateManager.kt:21 指该文件第 21 行;资源文件与 manifest 写仓库根相对全路径。

全流程

两个入口汇进同一次网络请求,之后按触发方式分流:

冷启动自动检查(可关)          关于页「获取更新」按钮
        │                           │
        └────────► fetchUpdateInfo ◄┘
                       │
      GitHub API 失败 ──► 抓镜像 release 页,正则取 tag
                       │
                       ▼
        VersionUtils.compare(远端版本, 本地版本)
          │有新版                    │无新版
          ▼                          ▼
  冷启动:关于页顶部横幅      手动:Snackbar「当前已是最新版本 v x.y.z」
  手动:弹 changelog 对话框
          │ 点「下载」
          ▼
  downloadApk → cacheDir,进度 0-100 回调
  (镜像下载失败自动换 GitHub 直连重试一次)
          │ 下载完成
          ▼
  Installing 状态 → ACTION_VIEW 拉起系统安装器

更新相关的代码分四个文件:util/UpdateManager.kt(网络、下载、安装)、util/UpdateInfo.kt(纯数据 + 解析)、util/UpdateNotifier.kt(启动检查的进程内缓存)、ui/screen/mine/AboutScreen.kt + UpdateChangelogDialog.kt(UI)。

更新源

主源是 GitHub Releases API,URL 硬编码在 util/UpdateManager.kt:21:

https://api.github.com/repos/lingion/sleepy/releases/latest

备用源是自建镜像的 release 页面(util/UpdateManager.kt:22-23),页面地址 https://gh.qdp.qzz.io/lingion/sleepy/releases/latest,下载前缀 https://gh.qdp.qzz.io/lingion/sleepy/releases/download/

网络层没有用 OkHttp 之类的库,直接 HttpURLConnection:连接超时 15 秒、读超时 60 秒、跟随重定向、User-Agent 固定为 Sleepy/<当前版本号>(util/UpdateManager.kt:124-130)。

fetchUpdateInfo 先请求 API;runCatching 包住,任何异常都落到镜像路径(util/UpdateManager.kt:39-44)。镜像路径拿 HTML 页面,用正则 /lingion/sleepy/releases/tag/(v[0-9A-Za-z.+_-]+) 抠出 tag,再拼出镜像下载地址(util/UpdateManager.kt:45-49)。两边都失败时抛出用户可见的错误「主站和镜像都没找到最新版本」(util/UpdateManager.kt:47,app/src/main/res/values/strings.xml:740)。

按设备 ABI 挑 asset。Build.SUPPORTED_ABIS 依序匹配 arm64-v8a → armeabi-v7a → x86_64,都不中就默认 arm64-v8a,asset 文件名形如 app-arm64-v8a-release.apk(util/UpdateManager.kt:25-30)。GitHub JSON 里找不到对应 asset 时 downloadUrl 返回空串,交给镜像路径重建(util/UpdateInfo.kt:42-47)。

还有一处 URL 改写:API 返回的 browser_download_url 指向 github.com 时,一律把域名替换成镜像(目录结构 1:1,util/UpdateInfo.kt:50-53)。「关于」页对用户的一句描述是「GitHub 主站不可用时,自动回退镜像下载」(app/src/main/res/values/strings.xml:614)。

版本比较

VersionUtils.compare 是纯数字段比较:用正则 \d+ 抠出全部数字段,逐位比,缺位补 0(util/VersionUtils.kt:5-17)。v 前缀会被剥掉,-debug 这类后缀因为不含数字天然不参与比较——1.0.25-debug1.0.25 相等,1.0.10 大于 1.0.9(app/src/test/java/com/lingion/sleepy/util/VersionUtilsTest.kt:8-11)。

是否算「有更新」由 parseReleaseJson 决定(util/UpdateInfo.kt:44-45):

val isUpdateAvailable = force ||
    VersionUtils.compare(version.ifBlank { "0" }, currentVersion) > 0

force 来自 release body 里的标记字符串 SLEEPY_FORCE_UPDATE=true(util/UpdateInfo.kt:16,43)。这个标记让同版本号也能强弹更新,测试锁定在 app/src/test/java/com/lingion/sleepy/util/UpdateInfoParseTest.kt:29-33。注意镜像路径构造的 UpdateInfo 只做版本比较,不检查 force 标记(util/UpdateManager.kt:50)。

检查时机

冷启动自动:MainActivity.onCreateUpdateNotifier.maybeCheckOnStart(MainActivity.kt:113)。检查由 SharedPreferences 开关门控,默认开(util/UpdateNotifier.kt:33,util/AppPrefs.kt:582-583)。结果只放进程内的 StateFlow,不写盘——进程被杀就丢,下次启动重查(util/UpdateNotifier.kt:19-29)。互斥锁加 inFlight 岗哨保证同一进程最多查一次,后到的请求直接忽略(util/UpdateNotifier.kt:36-40)。失败静默,横幅根本不出现(util/UpdateNotifier.kt:46)。

自动检查的呈现方式:横幅,零弹窗。AboutScreen 订阅这个 StateFlow,非 null 时页面顶部渲染「新版 %1$s 可用,点此查看」横幅、整页底色刷一层 5% 透明度的主题色(ui/screen/mine/AboutScreen.kt:81,195-216;app/src/main/res/values/strings.xml:656)。点横幅跳镜像的 tag 页,URL 在 AboutScreen.kt:211 拼出。

手动:「关于」页更新卡里的按钮,文案「获取更新」,Checking 状态下禁用并显示「正在检查更新…」(ui/screen/mine/AboutScreen.kt:290-301)。点击走 checkUpdate():有新版进 UpdateAvailable 弹 changelog 对话框;没新版进 NoUpdate,由 LaunchedEffect 弹 Snackbar「当前已是最新版本 v%1$s」然后回到 Idle(AboutScreen.kt:118-134,163-172)。

开关:「关于」页最底部有「自动检查更新」Switch(AboutScreen.kt:458-490)。关掉时写偏好并调 UpdateNotifier.clearCache(),横幅和高亮底色当场消失(AboutScreen.kt:477-481;util/UpdateNotifier.kt:51-55)。

下载

下载目标文件是 cacheDir/sleepy-update-app-<abi>-release.apk(util/UpdateManager.kt:58)。8KB 缓冲循环读写,进度按 已下载 * 100 / contentLength 计算并钳制在 0-100,每轮循环 ensureActive() 检查协程取消(util/UpdateManager.kt:83-91)。异常时删掉半截文件再抛(util/UpdateManager.kt:95-97);下完检查文件存在且非空,否则抛「下载文件为空」(util/UpdateManager.kt:70-71;app/src/main/res/values/strings.xml:741)。

镜像失败有一次直连重试:非取消类异常触发 toDirectGithubUrl 把镜像地址还原回 github.com 再下一次,还原前后地址相同(本来就直连)则直接抛原异常(util/UpdateManager.kt:61-69;util/UpdateInfo.kt:56-60)。CancellationException 原样上抛,不走重试(util/UpdateManager.kt:62)。

进度没有系统通知。下载百分比全部画在对话框里:LinearProgressIndicator 加标题「下载中 %1$d%%」(ui/screen/mine/UpdateChangelogDialog.kt:101-113;app/src/main/res/values/strings.xml:620)。UpdateNotifier 名字里带 Notifier,实际只是个 StateFlow 缓存对象,从不发 Android 通知(util/UpdateNotifier.kt:23-29)。

旧包清理在每次 MainActivity.onCreate:cleanOldApk 删掉 cacheDir 里所有 sleepy-update- 前缀文件(MainActivity.kt:107;util/UpdateManager.kt:104-107)。

安装

下载协程成功后,UI 进 Installing 状态,随即调 UpdateManager.install(ui/screen/mine/AboutScreen.kt:144-146)。安装就三步(util/UpdateManager.kt:109-116):

val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
    setDataAndType(uri, "application/vnd.android.package-archive")
    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)

intent 用的是 ACTION_VIEW 加 APK MIME 类型,把 URI 交给系统安装器;ACTION_INSTALL_PACKAGE 在代码里没有出现。权限侧靠两条 manifest 声明:REQUEST_INSTALL_PACKAGES(app/src/main/AndroidManifest.xml:8)和 FileProvider,authority ${applicationId}.fileprovider,路径规则只开 cache 目录(app/src/main/AndroidManifest.xml:47-55;app/src/main/res/xml/file_paths.xml 的 <cache-path name="updates" path="." />)。

弹窗此时无任何按钮,标题「下载完成,正在打开安装器」,等用户在系统安装器里确认(ui/screen/mine/UpdateChangelogDialog.kt:84,150;app/src/main/res/values/strings.xml:621)。

changelog 弹窗

UI 是一个 7 态状态机(ui/screen/mine/UpdateChangelogDialog.kt:31-39):Idle、Checking、NoUpdate、UpdateAvailable、Downloading、Installing、Failed。其中 4 个状态渲染对话框,Idle/Checking/NoUpdate 完全不弹(UpdateChangelogDialog.kt:51-52,156)。Downloading 状态下点对话框外区域不关窗,防止误触断下载(UpdateChangelogDialog.kt:77-79)。按钮随状态换:Available 给「取消/下载」,Downloading 只给「取消」,Failed 给「取消/重试」(UpdateChangelogDialog.kt:127-152)。

changelog 的渲染是自己写的确定性排版 MarkdownChangelog:块级支持标题(1-3 级映射 titleLarge/titleMedium/titleSmall)、圆点列表、段落;行内支持粗体、等宽代码、带下划线的强调色链接(UpdateChangelogDialog.kt:167-203,206-228)。解析器是 util/MarkdownBlocks.kt(object 定义在 14 行)。

changelog 文本有两条来源。API 路径直接取 release JSON 的 body 字段,本来就是 Markdown(util/UpdateInfo.kt:34-35)。镜像路径拿的是渲染后的 HTML,parseMirrorPage 定位第一个 classmarkdown-body<div>,截到第一个 </div>,再做两级 HTML→Markdown 转换(标题、列表、段落、粗斜体、代码、链接、实体解码,util/UpdateInfo.kt:75-83,86-118)。已知局限:markdown-body 内嵌套 <div>(如 <details>)会在第一个闭合标签处截断;本项目 release notes 是纯文本加列表,不受影响(util/UpdateInfo.kt:72-73)。找不到 markdown-body 块时返回空串,对话框跳过 changelog 区块只留标题(UpdateChangelogDialog.kt:115)。

失败处理

场景 行为 锚点
手动检查失败 Failed 弹窗显示错误文本,「重试」重新走 checkUpdate() ui/screen/mine/AboutScreen.kt:132,501-505
下载失败(镜像+直连都挂) Failed 弹窗保留版本/changelog/url,「重试」重新下载 AboutScreen.kt:147-153,504
用户取消下载 CancellationException → 回到 UpdateAvailable 弹窗,半截文件已删 AboutScreen.kt:148-149;util/UpdateManager.kt:62,96
下载内容为空 抛「下载文件为空」进 Failed util/UpdateManager.kt:70-71
主站和镜像都查不到版本 抛「主站和镜像都没找到最新版本」 util/UpdateManager.kt:47
冷启动检查失败 静默,横幅不出现 util/UpdateNotifier.kt:46
镜像页提不出 changelog 空串,弹窗不渲染该区块 util/UpdateInfo.kt:77-78

重试按钮的分派逻辑在 AboutScreen.kt:501-505:Failed 状态带 isCheckFailure 标记(检查阶段失败时为 true,AboutScreen.kt:132),true 就重查、false 就重下。

测试

解析与版本比较都有单测,纯函数、无 IO 依赖:

  • UpdateInfoParseTest,14 个 @Test:JSON 解析、ABI 挑选、force flag、下载地址镜像改写、直连还原、镜像页 changelog 提取(app/src/test/java/com/lingion/sleepy/util/UpdateInfoParseTest.kt)。
  • VersionUtilsTest,1 个 @Test:数字段比较、后缀忽略(app/src/test/java/com/lingion/sleepy/util/VersionUtilsTest.kt:7-12)。

相关页面

Clone this wiki locally