视频加载失败

ManyCourse UI 底层开发指南(架构 / 状态 / 渲染 / 性能)

4011 字
20 分钟
ManyCourse UI 底层开发指南(架构 / 状态 / 渲染 / 性能)

前言#

本文面向要改 UI 实现本身的开发(不是改业务数据),假设你已了解 Compose 基础和 Kotlin 语法。 姊妹文档:ManyCourse 课程表应用数据模型与后端接入指南(面向后端,讲数据模型与对接点)。 本文所有”实测”数字都来自 Android 14 真机(1080×2400 / 440dpi)逐像素采样,不是估算。

ManyCourse 是一款基于 Jetpack Compose 构建的 Android 课程表应用,覆盖登录认证、课表管理、日历浏览和个人设置等核心功能。本文将深入 UI 实现底层,完整拆解其分层架构、状态流转、渲染管线与性能优化策略。


1. 技术栈与结构#

项取值备注
语言 / 构建Kotlin 2.2.10、AGP 9.3.2、Gradle 9.5Compose 编译器插件随 Kotlin
UICompose(BOM 2026.02.01)+ 少量 XML登录页是 XML View,主界面全是 Compose
最低 / 目标minSdk 31、targetSdk / compileSdk 3731 是硬前提:RenderEffect / Haze 的实时模糊依赖它
模糊Haze 1.7.3只用 hazeSource + hazeEffect,不用 RenderScript
导航无 Navigation 组件Fragment add/hide/show 手工切换
持久化SharedPreferences(data/UiSettings.kt)项目没有 DataStore,未额外引入
MainActivity(XML 登录页,导出)
└─ startActivity → ManyCourseMain(Compose 宿主,未导出)
└─ fragment_container(FrameLayout)
├─ ClassScheduleFragment(TAB_INDEX=0)┐
├─ CalenderFragment (TAB_INDEX=1)├ 三个 Fragment 常驻,hide/show 切换
└─ SelfFragment (TAB_INDEX=2)┘
每个 Fragment = 一个 ComposeView
每个 Fragment 内部:Box{ hazeSource(背景) ; Column{ GlassHeader / 内容 / GlassBottomNav } ; 浮层 }

关键点:三个 Fragment 同时存在(不销毁),因此任何”逐帧成本”都会被乘以 3——见 §5。


2. 分层职责#

2.1 状态层#

文件内容约定
ui/MainTabStore.ktcurrentIndex(当前 Tab)全局单例,Activity 与 Compose 共享
data/Course.ktCourseRepository(课程,内存 mutableStateListOf)、ProfileRepository(昵称/专业)UI 直接读,改后自动重组
data/UiSettings.ktglassMode(玻璃风格)、notificationsEnabled(通知开关)+ SharedPreferences 读写唯一持久化入口,由 ManyCourseApp.onCreate 幂等初始化

规则:

  • 跨页面共享的状态放单例(MainTabStore / UiSettings / Repository),不要塞进某个 Fragment。
  • 派生值用 derivedStateOf 包住,只有当结果变化时才向下游传播失效(见 §5 R8 的实测收益)。
  • 不要新增”能推导出来的状态”(例如”今天的课”由仓库实时算,见 CourseRepository.todayCourseCount)。

2.2 令牌层(改样式只改这里)#

文件:ui/theme/GlassTokens.kt

GlassMode { Gaussian(默认), Liquid } // 设置页可切,持久化
GlassLevel { Bar, Panel } // Bar=页头/底栏;Panel=弹窗/表单
GlassEdge { None, Top, Bottom } // 分隔线画在哪一侧(None=完全不画)
GlassTokens // 所有颜色/圆角/阴影/模糊半径的唯一来源
LocalGlassTokens / LocalGlassMode // 由 ManyCourseTheme 提供

ManyCourseTheme 里依据 UiSettings.glassMode × isSystemInDarkTheme() 算出一份 GlassTokens 注入组合树,切换模式无需重启,所有组件自动重组。

令牌取值(浅色 / 深色):

令牌GaussianLiquid用途
barTint白 52% / #0F172A 62%白 46% / #0F172A 58%页头底栏底色(比内容亮一档,见 §3)
panelTint白 94% / #1E293B 94%白 90% / #1E293B 92%弹窗/表单面板
cardTint / cardTintHighlighted白 78% / 86%白 72% / 82%列表卡片(静态)
fieldTint#E3EBF7 / #16202F#E8EFFA / #16202F面板内输入框(必须比面板暗一档)
barBlur14dp16dp只有页头/底栏做实时模糊
barShadow12dp14dp页头下方投影的渐隐高度
panelShadow / cardShadow8dp / 3dp14dp / 6dp独立表面的阴影
highlight白 14%→2%→0白 30%→6%→2%顶部柔光
saturation0.94(灰纱收敛)1.10(品牌色增强)仅独立表面
edgeLight / innerShadow全透明 / 全透明白 60% / 黑 5%仅独立表面(液态玻璃的边缘光与厚度)
backgroundGradient浅色/深色各一组同上(彩度更高)页面底
blobAlphaScale0.75 / 0.71.0背景光斑色感
barSeparatorCampusSlate200 / #334155白 60% / 白 24%底栏与内容的分界线

新增样式差异时,先加令牌,再在 glassOverlays 里消费;不要在组件里写 if (mode == Liquid)。

2.3 渲染层#

文件:ui/components/LiquidGlass.kt

API用在哪是否实时模糊说明
LiquidGlassBackground(animate)每个 Fragment 的背景(hazeSource 内)—渐变 + 3 个轨道光斑;animate=false 时冻结
Modifier.liquidGlass(hazeState, shape, level, separatorEdge)只有页头/底栏✅ HazeBar 层:不画整圈描边、不吃内阴影/色感纱
Modifier.glassPanel(shape)弹窗/表单容器(GlassPanel)❌ 静态高不透明度 + 描边 + 阴影 + 边缘光 + 内阴影
Modifier.glassSurface(shape, highlighted)列表卡片、GlassCard❌ 静态零逐帧成本
glassOverlays(...)内部—高光 / 描边 / 内阴影 / 边缘光 / 色感纱的唯一实现点

2.4 组件层#

组件说明
GlassHeader(hazeState, title)56dp + 状态栏避让;四周不画线;下方带柔和投影(barShadow)
GlassBottomNav(hazeState, onSelect)64dp;内部读取 MainTabStore.currentIndex(见 §5 R8);顶部一条分界线
GlassOverlay(visible, onDismissRequest, panel)浮层宿主:遮罩淡入淡出 + 面板淡入/上移、返回键关闭、点击遮罩关闭、横屏安全区、IME 避让、面板限高 92%
GlassCard / GlassPanel / CourseCard / CampusChip / CampusButton静态玻璃与基础控件
isTabForeground(tabIndex)页面可见且前台的门控标志(冻结动画用)

约定:新玻璃组件必须走令牌 + glassOverlays,不要自己写一套背景/描边。

2.5 动效层#

位置实现
Tab 切换ManyCourseMain.selectTab() 里 setCustomAnimations(tab_enter, tab_exit, tab_pop_enter, tab_pop_exit)(res/anim/*,240/180ms,FastOutSlowIn,淡入淡出 + 8dp/-4dp 位移)
浮层进出场GlassOverlay 的 AnimatedVisibility(淡入淡出 + 轻微位移,不做缩放)
登录 → 主界面MainActivity.applyLoginTransition():API 34+ 用 overrideActivityTransition,低版本 overridePendingTransition(已 @Suppress)
统一参数ui/Motion.kt(时长、缓动、Motion.enabled())

尊重系统设置:Motion.enabled()(= ValueAnimator.areAnimatorsEnabled())为 false 时不挂动画/不设过渡。Compose 动画本身也跟随系统动画缩放。

2.6 适配层#

事项做法
状态栏 / 挖孔WindowInsets.statusBars ∪ displayCutout,.only(Top + Horizontal)(不含 IME)
底栏 / 手势条WindowInsets.navigationBars ∪ displayCutout,.only(Bottom + Horizontal)
中间内容WindowInsets.safeDrawing.only(Horizontal)(竖屏为 0,横屏/异形屏才生效)
XML 登录页MainActivity 的 insets 监听:systemBars() or displayCutout(),四边都加 padding
屏幕方向两个 Activity 都 android:screenOrientation="portrait"(去掉系统”旋转屏幕”黑色提示;Android 16+ 大屏会忽略该限制)
深色Compose 走 MaterialTheme 角色色;XML 登录页走 values-night/colors.xml
多语言应用名:values-zh/strings.xml 覆盖 app_name=课多多,其他语言回退 values/strings.xml

3. 硬性规则(每条都踩过)#

违反这些规则会产生”看起来像 bug”的视觉效果,而且很难从代码上看出来。

R1 · 半透明玻璃后面禁止垫 Modifier.shadow

阴影会透过半透明玻璃,把整条压暗。

✅ 正确:投影画在玻璃下方(GlassHeader 里那条 barShadow 高度、由 shadowColor 渐隐到透明的 Box)。

❌ 实测:给页头加 Modifier.shadow(5.dp) 后,页头亮度从 234.8 掉到 218.7(比内容还暗)。

R2 · 页头/底栏不吃”独立表面”的三种装饰:整圈描边、底部内阴影、色感纱层。

  • 整圈描边:在贴屏幕边缘的那侧会被圆角屏/曲面屏裁掉,出现”线被圆角切断”
  • 内阴影(Liquid 黑 5%):让”顶部”看起来沉在内容下面
  • 色感纱层(蓝纱):压 R 通道,页头比内容暗

✅ 实现:glassOverlays(standalone = ...),standalone = level == Panel(页头/底栏传 false)。

R3 · 会做进出场动画的表面禁止实时模糊

Haze 的 RenderEffect 放在缩放/淡入图层里会重采样 → 表现为”卡片闪几下”。

✅ 弹窗/表单用静态 glassPanel(本来不透明度就 90%+,模糊观感差异极小)。

R4 · 贴屏幕物理边缘不画线

只在”与内容相接”的一侧画分界线;页头当前完全不画(内容在自己的滚动容器里,不会滚到页头下);底栏画顶边。

R5 · 带 insets padding 的 ViewGroup 必须 clipToPadding="false"

ViewGroup 默认 clipToPadding=true,会在 padding 边界裁掉子 View。

❌ 实测:登录页装饰光斑被状态栏那条线切断,y≈94 处出现 4.8 级亮度台阶(“状态栏和页面色差”)。

✅ activity_login.xml 的 loginContainer 已显式设为 false。

R6 · MaterialCardView 不要设 cardElevation(或不透明度要求”背景铺满”时)

它会按阴影给自身背景加内边距 → 卡片背景比控件内缩一圈,看起来像”卡片里还有一张小卡片”。

❌ 实测:登录卡片背景在 y=1160 那行从 x≈80 起,而不是卡片边界 66。

✅ 层次靠底色 + 1dp 描边;cardPreventCornerOverlap/cardUseCompatPadding=false。

R7 · 列表/滚动场景不用实时模糊

GlassCard/CourseCard 走 glassSurface(静态)。只有两个页头/底栏共 2 处实时模糊。

R8 · 状态读取位置决定重组范围

MainTabStore.currentIndex 如果在 Fragment 组合作用域里读(例如作为 GlassBottomNav 的参数),切一次 Tab 会让三个全屏 Fragment 全部重组。

✅ 两处修正:① GlassBottomNav 内部读取索引;② isTabForeground 用 derivedStateOf 包住 tab 比较 → 只有可见性真正变化的那一页重组。

R9 · 隐藏页 / 退后台必须冻结动画

三个 Fragment 常驻,隐藏页不绘制但动画仍在逐帧跑。

✅ isTabForeground + LiquidGlassBackground(animate):用 Animatable + LaunchedEffect(animate),不可见时协程取消(数值冻结),恢复时按 LinearEasing 从当前值续走,位置不跳变。

rememberInfiniteTransition 没有公开的暂停 API,所以没用它。

R10 · 动画值要量化后再喂给图层

角度步进 1°、缩放步进 1%(derivedStateOf):光斑是超柔和渐变 + 32 秒一圈,肉眼无差,但背景层失效频率从 60/s 降到约 15/s,Haze 的重模糊次数同步下降。

R11 · 文字颜色走 MaterialTheme.colorScheme 角色,不要硬编码 CampusSlate900/500/200

本项目浅色方案的 onSurface/onSurfaceVariant/outline 取值与原来的硬编码完全一致,所以替换是零视觉变化的,但深色模式会立刻正确。

XML 登录页没有角色色可继承,因此用 @color/login_* + values-night 覆盖。

R12 · 不引入废弃 API;生命周期用 androidx.lifecycle.compose.LocalLifecycleOwner

androidx.compose.ui.platform.LocalLifecycleOwner、overridePendingTransition(API34+)、isStatusBarContrastEnforced(API35+) 都已废弃/受限:

  • 生命周期:依赖 lifecycle-runtime-compose
  • 状态栏对比度:ui/WindowExt.kt 里统一 @Suppress("DEPRECATION")
  • 应用内 screenOrientation 已加 tools:ignore="LockedOrientationActivity"

4. 常见扩展配方#

4.1 新增一个页面(Tab)#

  1. 新建 XxxFragment:照抄 SelfFragment 结构(hazeSource 背景 + GlassHeader + 内容 Box(横向安全区)+ GlassBottomNav + 浮层),TAB_INDEX 取 0/1/2。
  2. ManyCourseMain 的 tabFragments 与 TAB_* 常量同步加。
  3. GlassBottomNav 的 items 列表加一项(图标放 AppIcons)。
  4. 页面内容用 GlassCard 包卡片;文字用 MaterialTheme.colorScheme 角色色。

4.2 新增/调整样式#

只改 ui/theme/GlassTokens.kt:加字段 → 在 glassTokens(mode, dark) 两处(Gaussian / Liquid)都给值 → 在 glassOverlays 或组件里消费。

⚠️ 同步更新 app/src/test/java/com/tof/manycourse/GlassTokensTest.kt 的区间断言(该测试会拦住”面板比卡片还透""输入框和面板同色”这类退化)。

4.3 新增浮层(对话框/表单)#

@Composable
fun XxxDialog(visible: Boolean, onDismiss: () -> Unit) {
GlassOverlay(visible = visible, onDismissRequest = onDismiss) {
GlassPanel(cornerRadius = 24, modifier = Modifier.fillMaxWidth()) { /* 表单 */ }
}
}
  • 调用方常驻组合它(visible 控制),否则没有退出动画
  • 表单状态在 LaunchedEffect(visible) { if (visible) { /* 重置 */ } } 里重置
  • 面板内部不要用实时模糊(R3)

4.4 接入真实后端数据#

现有:api/HttpMethod.kt(OkHttp + JSON,同步 execute())、gr_api/GRClassAPI.kt(loginSystem(account, password, url)),目前未被 UI 调用。数据都来自内存仓库。

接法建议(保持 UI 不变):

  1. 把 CourseRepository 的 courses: SnapshotStateList<Course> 保留为唯一 UI 数据源,网络层只负责填充它(courses.clear(); courses.addAll(remote))→ 所有页面自动刷新。
  2. 网络调用放 viewModelScope/Dispatchers.IO,不要在组合/主线程直接 execute()。
  3. 增加三态(加载中 / 空 / 错误)时,用现有的静态玻璃组件包一层,例如空态参考 CalendarScreen 的”当日无课”卡片。
  4. 登录成功后除 startActivity 外,把账号态放进单例(参考 UiSettings),供后续接口鉴权。
  5. 字段口径见本文姊妹篇第 2 章(weekday 1=周一、节次 1~10 等)。

5. 性能清单#

项现状
实时模糊点仅 2 处(页头、底栏),各 Fragment 一份
隐藏页动画冻结(isTabForeground)
退后台冻结(生命周期 STARTED 门控)
图层失效频率量化后约 15/s(原 60/s)
列表卡片静态表面,零逐帧成本
弹窗面板静态表面(同时规避 R3 的闪帧)
release 构建optimization { enable = true }(R8 已开启)

排查卡顿的次序:

  1. 是否 debug 包(debug 明显更慢,请用 release 复测)
  2. 是否有新的实时模糊/每帧失效
  3. 是否有状态被放在大作用域里读(R8)
  4. 是否有”进后台还在跑的动画”(R9)

6. 构建与验证工作流#

Terminal window
:: Windows:gradlew.bat;macOS/Linux 用 ./gradlew
gradlew.bat :app:assembleDebug :: 要求 0 error / 0 warning(含废弃 API 警告)
gradlew.bat :app:assembleRelease :: 验证 R8 下也能过
gradlew.bat :app:testDebugUnitTest :: GlassTokensTest:令牌区间/层级断言
gradlew.bat :app:assembleDebugAndroidTest :: 构建插桩测试 APK
adb install -r -t app/build/outputs/apk/debug/app-debug.apk
adb install -r -t app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
adb shell am instrument -w -e class com.tof.manycourse.GlassUiTest ^
com.tof.manycourse.test/androidx.test.runner.AndroidJUnitRunner

GlassUiTest 覆盖:玻璃风格切换即时生效 + 持久化 + 重建保持、添加课程浮层返回键关闭、底部导航切换、通知开关可关 + 持久化。

6.1 视觉改动的验收手法(无 UI 自动化也能客观验证)#

  1. uiautomator dump:拿控件精确 bounds(adb shell uiautomator dump /sdcard/u.xml + adb pull),用于判断间距/内缩/贴边。
  2. 逐像素剖面:adb exec-out screencap -p 存图后,用脚本沿 x 或 y 逐像素输出 RGB/亮度,找”台阶”与”暗带”。
    • 例:页头下边界 1px 细扫,可判断有没有分隔线残留
    • 例:y=0..6 细扫,可判断系统是否在物理顶边画了东西
  3. aapt2 dump badging:验证多语言资源(应用名等)与图标是否按 locale 打包正确。
  4. 临时调试技巧(用完必须还原):把 ManyCourseMain 临时设为 exported="true" 以便直接启动主界面取图;把 showAddCourse 初值临时设为 true 以便打开浮层取图。

6.2 工具陷阱(重要)#

  • 不要用 PowerShell 的 Set-Content/Out-File 改源码:该环境按 ANSI 落盘,会把含中文的 UTF-8 源文件写成无效 UTF-8(本次已因此损坏并重建 5 个文件)。改文件用 IDE/编辑器。
  • MIUI 设备禁止 shell 注入输入:adb shell input tap/keyevent/text 报 SecurityException: INJECT_EVENTS,因此无法用 adb 驱动 UI;交互验证要么走插桩测试,要么靠上面的像素/层级手法。
  • MIUI 可能拒绝安装测试 APK:INSTALL_FAILED_USER_RESTRICTED(需在开发者选项里打开”通过 USB 安装应用”),此时 am instrument 无法运行。
  • 无线调试的 ddmlib 不稳:connectedAndroidTest 可能报 Unknown API Level / ShellCommandUnresponsiveException,改用上面的 adb install + am instrument 手动路径。

7. 已知取舍与待办#

项现状 / 建议
弹窗面板不做实时模糊为消除进出场闪帧(R3);若日后要恢复,必须在动画结束后再开启模糊,且不能用缩放
页头无分隔线靠”更亮的底色 + 下方柔和投影”表达层次;若想更明确,调 barSeparator 并改 GlassEdge.Bottom
登录页图标已去掉玻璃圆盘与投影(待替换正式图标,直接换 android:src)
竖屏锁定手机生效;Android 16+ 大屏会忽略,页面已做横屏安全区适配,不会错版
深色模式Compose 侧已按角色色适配;XML 登录页靠 values-night。新加 XML 页面请照做
数据层仍是内存仓库,GRClassAPI 未接线(见 §4.4)
通知开关仅持久化开关状态,尚未接 NotificationManager/WorkManager 真正推送
AppIcons自绘 ImageVector(lazy);新增图标沿用该方式,避免引入图标库

附录:数据模型速查#

本节为后端接入时需对齐的字段口径,详细对接方案见姊妹篇。

A.1 Course 课程模型#

字段类型必填约束
idLong是本地自增,后端可替换为服务端 id
nameString是非空
teacherString否可为空字符串
roomString否为空时前端默认填「地点待定」
weekdayInt是1=周一 … 7=周日
startPeriodInt是1~10
periodCountInt是1~4,且 start + count - 1 ≤ 10

A.2 节次-时间对照表#

节次12345678910
时间8<00>8<55>10<10>11<05>14<00>14<55>16<10>17<05>19<00>19<55>

A.3 Profile 个人信息#

字段类型默认值
nicknameString“张同学”
majorString“计算机科学 · 2022级”

A.4 后端 JSON 示例#

{
"id": 1024,
"name": "高等数学",
"teacher": "王建国",
"room": "教学楼 A-101",
"weekday": 1,
"startPeriod": 1,
"periodCount": 2
}

如需支持单双周、调课等高级场景,建议扩展:

{
"weeks": [1, 2, 3, 4, 5, 6, 7, 8],
"weekType": "ALL"
}

weekType 取值:"ALL"(全周)、"ODD"(单周)、"EVEN"(双周)。

A.5 后端接入要点#

端点方法说明对应前端调用点
/loginPOST用户登录MainActivity 登录按钮
/coursesGET获取全部课程ManyCourseMain 初始化
/coursesPOST新增课程CourseRepository.add()
/courses/{id}DELETE删除课程CourseRepository.remove()
/profileGET获取用户资料「我的」页加载
/profilePUT更新昵称「我的」页保存

接入原则:

  • CourseRepository.courses(SnapshotStateList<Course>)是唯一 UI 数据源,网络层只负责 clear() + addAll()
  • 网络调用放 Dispatchers.IO,不在主线程/组合内 execute()
  • 项目已引入 okhttp 5.5.0 + logging-interceptor,可直接使用
  • 离线兜底建议先接 Room 或 SharedPreferences 做本地缓存
  • weekday=1 是周一、weekday=7 是周日,后端接口务必保持一致

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

ManyCourse UI 底层开发指南(架构 / 状态 / 渲染 / 性能)
https://ksayus.top/posts/manycourse-backend-integration/
作者
Ksayus
发布于
2026-09-15
许可协议
GPLv3

评论区

Profile Image of the Author
Ksayus
细碎的美好也是生活的意义
Ksayus の 猫窝
This is Ksayus`s blog, is will be write some tech article.
分类
标签
最新动态
站点统计
文章
35
分类
7
标签
111
总字数
58,408
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.8
文章许可
GPLv3
文章目录