00 · 总览与架构
一句话职责 :给出安卓端 archive-management-app 的整体架构、分层与调用约定、构建与部署形态、跨端硬契约,作为其余 17 篇模块文档的共同前提。
本篇入口 :无(所有模块共享的基础设施)
对接后端 :Spring Boot(archive-management-backend),无 context-path,接口挂根路径
源码范围
| 源码 |
职责 |
MainApplication.kt |
Application 入口:安装崩溃记录、启动 Koin |
MainActivity.kt |
唯一 Activity(FragmentActivity、singleTop),承载 Compose 与通知深链投递 |
di/AppModule.kt |
全部 Koin 装配:21 个 single + 17 个 viewModel |
data/remote/Constants.kt |
BASE_URL / API_PREFIX 两个环境值 |
build.gradle.kts(根 + app) |
插件版本、SDK 与编译选项、依赖清单 |
settings.gradle.kts |
仓库镜像(阿里云优先)与模块声明 |
AndroidManifest.xml |
权限、组件(Activity / Service / Receiver / Provider)声明 |
res/values/themes.xml、res/values-v31/themes.xml、res/drawable/splash_* |
启动主题与闪屏 |
res/xml/network_security_config.xml |
明文 HTTP 放行白名单 |
1. 模块概述
1.1 交付形态
单个 Android 应用(applicationId = com.lvyq.archiveapp),与 Web 中台、后端共享同一套接口。业务覆盖档案全生命周期:收集(人员/合同/设备/报告四类主档)→ 整理(实体子件入盒)→ 保管(档案盒/档案室)→ 利用(借阅申请、审批、核销)→ 盘点与鉴定销毁。
1.2 技术栈与版本
| 类别 |
选型 |
版本 |
| 语言 |
Kotlin(JVM 目标 17) |
2.0.21 |
| 构建 |
AGP / Gradle Wrapper |
8.11.0 / 8.13(腾讯镜像) |
| UI |
Jetpack Compose + Material3 |
BOM 2024.09.00(Compose 1.7.0 / Material3 1.3.0) |
| 适配 |
compileSdk / targetSdk / minSdk |
34 / 34 / 24 |
| 网络 |
Retrofit + converter-gson / OkHttp |
2.11.0 / 4.12.0 |
| 长连接 |
OkHttp WebSocket |
4.12.0 |
| 本地存储 |
DataStore Preferences |
1.0.0 |
| 依赖注入 |
Koin(纯 Kotlin,无注解处理器) |
3.5.3 |
| 二维码 |
ZXing core(生成)/ zxing-android-embedded(扫码) |
3.5.3 / 4.3.0 |
| 生物识别 |
BiometricPrompt + EncryptedSharedPreferences |
1.1.0 / 1.1.0-alpha06 |
| 导航 |
无第三方导航库,自研状态机 + 深链 |
--- |
1.3 依赖关系
- 强依赖后端:所有业务数据均来自后端,App 无离线业务能力(
CacheStore 只做弱网缓存补偿,见 02 篇)。
- 强依赖后端菜单:首页宫格入口由
GET /system/menu/app 下发,本地 AppMenuConfig 仅作离线兜底(见 06 篇)。
- 弱依赖 Web 端:仅「扫一扫登录」需要 Web 端展示二维码(见 05 篇)。
2. 架构与运行骨架
2.1 分层与单向数据流
ui/**/*Screen.kt Compose 界面(无业务逻辑,只订阅状态 + 派发事件)
│ collectAsStateWithLifecycle(uiState)
▼
ui/**/*ViewModel.kt StateFlow<UiState>;提交统一 try/catch(Throwable)
│ 调用
▼
data/repository/** 纯数据编排:调接口、拼参数、映射错误;不依赖 Android 框架
│
▼
data/remote(Retrofit / WS) data/local(DataStore)
约定:
- UI 状态单一真相源 :每个 ViewModel 暴露一个
StateFlow,Compose 侧用 collectAsStateWithLifecycle 订阅。
- 异常不上抛到 UI :ViewModel 内部捕获并转换成状态字段(错误文案 / 空态),配合
safeLaunch 与 CrashLogger 兜底,避免单点异常闪退。
- Repository 不做 UI 决策 :文案映射只做「技术错误 → 用户可读文案」(
NetError),业务分支留给 ViewModel。
2.2 依赖注入装配(di/AppModule.kt)
| 分组 |
实例 |
| 基础设施(4) |
SessionStore、CacheStore、ApiClient(baseUrl)、MessageWsClient(baseUrl) |
| Repository(17) |
AuthRepository、ArchiveRepository、ProfileRepository、BorrowRepository、DictRepository、WorkflowRepository、QrLoginRepository、NoticeRepository、MessageRepository、StatsRepository、InventoryRepository、BoxRepository、FileRepository、SearchRepository、MenuRepository、AppraisalRepository、HomeSummaryRepository |
| ViewModel(17) |
LoginViewModel、ArchiveListViewModel、ProfileViewModel、BorrowViewModel、ScanViewModel、BorrowApproveViewModel、BorrowHandleViewModel、BorrowApproveListViewModel、BorrowApplyViewModel、QrLoginViewModel、NoticeListViewModel、NoticeDetailViewModel、MessageViewModel、InventoryViewModel、BoxQueryViewModel、MainViewModel、AppraisalViewModel |
ApiClient 与 MessageWsClient 都接收同一个 BASE_URL;前缀补全由 ApiPrefixInterceptor 在 OkHttp 层完成,二者共用(见 01 篇)。
- Compose 中取 ViewModel 用
koinViewModel();在非 Composable 环境取单例用 GlobalContext.get().get<T>()。
2.3 启动序列
系统闪屏(API 31+ 走 values-v31 的 windowSplashScreen 红章 AVD;API 24--30 显示静态红章 splash_bg)
▼
MainApplication.onCreate CrashLogger.install(this) → startKoin { appModule }
▼
MainActivity.onCreate handleIntent(intent) → setContent { ArchiveAppTheme { AppNavigation() } }
▼
AppNavigation 读本地登录态:无有效 token → 登录页;有 → MainScaffold
▼
MainScaffold 加载动态菜单、拉首页大盘、申请通知权限并拉起推送前台服务
通知点击走 onNewIntent(Manifest 声明 launchMode="singleTop"):NavTarget.from(intent) → PendingNavTarget.post(StateFlow)→ AppNavigation → MainScaffold 消费。Activity 只投递不跳转 ,因为冷启动时 AppNavigation 还停在登录态恢复的等待态,MainScaffold 尚未挂载。
2.4 承载方式
全应用只有一个 Activity (MainActivity),其余页面均为 Compose 内的覆盖层/子页状态,扫码页由 zxing-android-embedded 的 CaptureActivity 承担(Manifest 中锁定竖屏)。
3. 工程结构与构建
3.1 目录结构
app/src/main/java/com/lvyq/archiveapp/
├── MainApplication.kt / MainActivity.kt
├── data/remote/ ApiClient、Constants、MessageWsClient、19 个 *Api、54 个 model
├── data/local/ SessionStore(登录态/订阅/历史)、CacheStore
├── data/repository/ 17 个 Repository
├── di/ AppModule
├── domain/menu/ AppMenuConfig(离线兜底菜单)、MenuIcons(矢量图标)
├── ui/ main / login / qrlogin / navigation / archive / electronic / borrow
│ box / inventory / appraisal / notice / message / profile / theme / common
├── service/ MessagePushService(WS 前台服务)
├── receiver/ BootReceiver
└── util/ CrashLogger / NotifyHelper / NetError / PdfPreview / QrSaver / SafeClick 等
3.2 编译与产物
| 项 |
值 |
applicationId |
com.lvyq.archiveapp |
versionCode / versionName |
1 / 1.0.0(ApiClient 经 BuildConfig.VERSION_NAME 拼 User-Agent) |
| Java / Kotlin 目标 |
17 / 17 |
buildFeatures |
compose = true、buildConfig = true |
| release 混淆 |
isMinifyEnabled = false(保留可读堆栈,便于线上用 logcat 定位) |
| release 签名 |
buildTypes.release 未配置 signingConfigs ,assembleRelease 产出的是未签名包,需要另行签名后才能安装 |
| 仓库镜像 |
settings.gradle.kts 中阿里云镜像优先,FAIL_ON_PROJECT_REPOS 禁止模块私自声明仓库 |
3.3 清单与权限(AndroidManifest.xml)
| 组件 / 权限 |
说明 |
MainActivity |
exported=true、launchMode=singleTop、主题 Theme.ArchiveApp |
CaptureActivity(zxing) |
扫码取景,锁定竖屏 |
MessagePushService |
foregroundServiceType="dataSync",exported=false |
BootReceiver |
监听 BOOT_COMPLETED 与 MY_PACKAGE_REPLACED;声明 exported=true 是因为这两个都是受保护广播,只有系统可发,放开不引入外部触发面,却能兼容部分 ROM 的投递差异 |
FileProvider |
authorities=${applicationId}.fileprovider,预览不支持内嵌渲染的文件时授权外部应用打开 |
INTERNET / ACCESS_NETWORK_STATE |
访问后端、网络状态判断 |
CAMERA(neverForLocation) |
扫码核销 / 审核 / 登录 |
POST_NOTIFICATIONS |
Android 13+ 发送本地通知需运行时授权 |
FOREGROUND_SERVICE + FOREGROUND_SERVICE_DATA_SYNC |
维持 WS 长连接(Android 14+ 必须声明类型) |
RECEIVE_BOOT_COMPLETED |
开机 / 升级后恢复消息提醒 |
USE_BIOMETRIC |
指纹快捷登录 |
4. 关键设计决策
| 决策 |
为什么这样 |
不用 androidx.navigation |
本应用是「单栈覆盖层」形态:底部两个常驻 Tab + 一批全屏子页,页面间没有深层嵌套路由与参数化路径。用一组布尔状态即可表达,且能天然做到「进二级页隐藏底栏」「返回键逐层回退」。引入 NavHost 反而要为每个覆盖层再定义一条 route 并维护回栈语义。 |
前缀放在 OkHttp 拦截器,而不是 BASE_URL |
Retrofit 对以 / 开头的相对路径按主机根 解析,会丢弃 base URL 的 path 部分;而全部接口注解都写成 @GET("/archive/..."),所以前缀只能由拦截器补。这样切换部署形态只改一个常量(见下)。 |
MainActivity.onNewIntent 只投递,不直接跳转 |
冷启动场景下 Compose 还停在登录态恢复的等待态,MainScaffold 未挂载;用 StateFlow 暂存「一次性目标」,由已挂载的页面自己消费并置空,避免丢目标或重复跳转。 |
WS 常驻前台服务(dataSync) |
消息提醒要求秒级到达且 App 退到后台也要在;只有前台服务能稳定持有长连接。服务内部自检登录态与通知开关,条件不满足即自行退出,避免无效常驻。 |
| 通知 id 按 type 派生 |
同一时刻只留同类最新一条、不同类型互不顶替;且 id 必须与 PendingIntent 的 requestCode 同值,否则多条通知共用一个 PendingIntent,点哪条都跳同一页(见 16 篇)。 |
开 buildConfig = true |
AGP 8 默认不再生成 BuildConfig,而 User-Agent 依赖 BuildConfig.VERSION_NAME。 |
| release 不开混淆 |
便于用线上 logcat 直接定位堆栈;当前未接崩溃上报平台,保留可读类名比压缩体积更重要。 |
5. 部署
5.1 两种部署形态(只改一个文件)
| 形态 |
BASE_URL |
API_PREFIX |
实际请求 |
| 直连后端(开发 / 内网) |
http://192.168.1.100:8080/ |
"" |
http://192.168.1.100:8080/auth/login,WS /ws/message |
| 经 nginx 统一入口(生产) |
https://archive.example.com/ |
"/api" |
https://archive.example.com/api/auth/login,WS /api/ws/message |
两个值都在 data/remote/Constants.kt。注意:BASE_URL 只写到 host + 端口,且必须以 / 结尾 (Retrofit 强制要求);不要再把 /api 写进去(前缀会被丢弃但容易误导,详见 01 篇)。
nginx 侧必须配 proxy_pass http://127.0.0.1:8080/;(末尾 / 剥掉 /api);WebSocket 还需 proxy_http_version 1.1 + 透传 Upgrade/Connection + 读超时放大到 300s,完整片段见仓库 README.md 与 16 篇。
5.2 打包与安装
# 调试包(最常用)
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 发布包:当前未配置 signingConfigs,产物未签名
./gradlew assembleRelease
# 需自行签名后再安装:
# apksigner sign --ks <keystore> --out app-release-signed.apk app/build/outputs/apk/release/app-release-unsigned.apk
5.3 必改配置
| 场景 |
要改什么 |
| 换后端地址 |
data/remote/Constants.kt 的 BASE_URL(+ 经 nginx 时 API_PREFIX = "/api") |
| 后端是明文 HTTP |
res/xml/network_security_config.xml 的 domain-config 里加该 IP 或域名 |
| 上生产 HTTPS |
可移除整个 network_security_config.xml 与 Manifest 中的引用 |
| 版本号 |
app/build.gradle.kts 的 versionCode / versionName(后者会进 User-Agent) |
5.4 后端需要具备的能力
- 接口版本包含:动态菜单
/system/menu/app、统一检索 /archive/search、借阅全链路、盘点、鉴定销毁、消息 /system/message/*、文件 /file/{id} 与 /file/preview-pdf/{id}。
- WebSocket 端点
/ws/message 可用(否则 App 只保留消息列表,不弹通知)。
- Office 预览依赖后端装 LibreOffice(
/file/preview-pdf/{id} 做转换)。
- 准备账号:至少一个 admin(盘点/鉴定/档案盒)、一个 leader(审批)、一个 staff(申请借阅)。
6. 验证清单
| 项 |
操作步骤 |
预期结果 |
观察点 |
| 安装启动 |
adb install -r app-debug.apk 后点图标 |
系统闪屏红章 → 登录页(或已登录直接进首页),无白屏 |
logcat 无 FATAL EXCEPTION |
| Koin 装配 |
启动应用 |
无 NoBeanDefFoundException |
logcat 过滤 Koin |
| 登录态持久化 |
登录 → 杀进程 → 重开 |
免登录直接进首页 |
SessionStore 有 token |
| 网络前缀 |
首页下拉/切 Tab 触发刷新 |
请求路径带 /api 前缀(nginx 形态)且返回 200 |
logcat OkHttp --> GET .../api/... |
| 动态菜单 |
用不同角色账号登录 |
宫格入口按角色变化 |
GET /system/menu/app 返回码与数组 |
| 明文 HTTP |
后端用 http://,IP 不在白名单 |
请求报 CLEARTEXT communication not permitted |
加 IP 后恢复正常 |
| WS 连通 |
登录后停留首页 |
前台服务通知常驻,收到业务终态时弹通知 |
logcat 过滤 MessageWsClient / MessagePushService |
| 深链 |
点通知 |
直接落到对应页面,且不重启 App |
MainActivity.onNewIntent 被调用 |
| 崩溃回显 |
人为触发一次崩溃后重开 |
首页弹上次崩溃摘要,可复制堆栈 |
私有目录 crash_last.txt |
| 打包 |
./gradlew assembleRelease |
产出未签名 release 包(属预期) |
app/build/outputs/apk/release/ |
失败排查
| 现象 |
原因 |
处理 |
| 接口全 404 |
API_PREFIX 与部署形态不匹配 |
nginx 形态填 /api;直连填 "" |
| 接口通但 WS 连不上 |
nginx 未开 proxy_http_version 1.1 / 未透传 Upgrade |
见 16 篇与 README 的 nginx 片段 |
安装失败 INSTALL_FAILED_UPDATE_INCOMPATIBLE |
签名不一致 |
先 adb uninstall com.lvyq.archiveapp |
| 登录后白屏 |
菜单接口失败且兜底未生效 / token 无效 |
logcat 看 MainViewModel 与 /system/menu/app 返回 |
| 收不到通知 |
通知权限未授权或订阅类型未勾选 |
系统设置授权 + 「我的 → 通知接收类型」 |
7. 已知边界
- release 未配置签名与混淆 :
assembleRelease 直接产出未签名包;如需上架需补 signingConfigs 与混淆规则。
- 深色模式不完整 :
Theme.kt 跟随系统并定义了 DarkColors,但业务界面大量直接使用 Brand.* 与字面量颜色,深色下卡片不会反转(见 04 篇)。
- 无独立报表页 :存量总览以「首页家底台账」形式呈现;
StatsRepository.borrowTrend()(/archive/stats/borrow-trend)已封装但端上无消费(见 06 篇)。
- 无离线业务能力 :
CacheStore 仅覆盖鉴定与个人中心两处弱网补偿,其余页面断网即空态。
- 消息只推新增不补存量:连接建立前产生的消息不经 WS 推送,需由各入口自行拉全量(见 16 篇)。
- App 重启后推送依赖用户打开 App :Android 12+ 对后台启动前台服务有限制,
BootReceiver 只能尽力拉起(见 16 篇)。