外观
Android 端编码规范
本文档定义 CodeNote Android 客户端的核心编码规范,覆盖信息提示与错误处理、跨页面通讯、表单状态、配额、图片上传、界面设计等关键场景。 所有业务开发人员在编写、Review 代码时均应严格遵守。
目录
- 1. 概述与约定
- 2. 统一信息提示与错误处理
- 3. 统一跨界面通讯
- 4. 表单临时状态管理与跨页面数据传递
- 5. 配额配置管理
- 6. 图片上传规范(OSS + STS 直传)
- 7. 界面整体风格
- 8. 颜色
- 9. Typography
- 10. 通用页面模板(核心布局规范)
- 11. 其他界面规范
- 12. 桌面小部件规范
1. 概述与约定
1.1 适用范围
本规范适用于 CodeNote Android 客户端全部业务代码,重点覆盖以下场景:
- 应用内统一信息提示与错误处理(Toast、Snackbar、Repository / ViewModel / UI 三层)
- 应用内跨页面 / 跨层级通讯
- 表单临时状态管理与跨页面数据传递
- 配额配置的获取与使用
- 图片上传(OSS + STS 直传)
- Android 界面设计规范(通用页面布局、颜色、字体、组件使用)
1.2 标记与 emoji 规范
文档统一使用以下标记:
| 标记 | 含义 |
|---|---|
| ✅ | 推荐 / 正确做法 |
| ❌ | 不推荐 / 错误做法 |
| ⚠️ | 注意事项 / 风险点 |
| 💡 | 提示 / 技巧 |
代码块内的 ✅ / ❌ 仅作为正反例注释使用,不影响实际运行逻辑。
1.3 章节依赖图
┌───────────────────────────────────────┐
│ 2. 统一信息提示与错误处理 │ ← 基础能力
└────────────────┬──────────────────────┘
│
┌────────────────▼─────────────────┐
│ 3. 统一跨界面通讯 │ ← 基础能力
└────────────────┬─────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌──────▼──────┐ ┌───────▼────────┐ ┌──────▼──────┐
│ 4. 表单与 │ │ 5. 配额配置 │ │ 6. 图片上传 │
│ 跨页面传递 │ │ │ │ │
└─────────────┘ └────────────────┘ └─────────────┘核心原则:有明确"发送方 → 接收方"对应关系时用 savedStateHandle(种子参数)或 FormStateManager(表单状态/回传,详见第 4 章);广播式通知用 EventBus(第 3 章);所有 UI 反馈与错误处理统一走 ToastManager / ToastHost / 三层错误处理(第 2 章)。
2. 统一信息提示与错误处理
前置章节:无 后续章节:第 3 章(EventBus 携带 ShowToast 事件)、第 4 章(表单清理状态走错误处理)、第 5 章(前端预校验走错误处理)、第 6 章(图片上传错误走错误处理)
涉及模块:
core:ui—ToastHost.kt+ToastManager.ktcore:data—safeApiCall(Repository 层错误兜底)core:common—Resource封装、EventBus(ViewModel 层通知)
2.1 概述
本章合并了应用内信息提示与错误处理两大主题,二者在数据流上紧密耦合:
- 信息提示:ToastManager + ToastHost,通过 EventBus 协作
- 错误处理:Repository safeApiCall → ViewModel 分发 → UI Toast 展示
架构优势:
- ✅ 统一管理:所有提示与错误通过单一入口,样式与流程一致
- ✅ 使用简单:一行代码即可显示提示;统一的 Resource 包装让错误处理线性化
- ✅ 解耦设计:业务代码无需关心 UI 实现细节
- ✅ 类型安全:编译期检查,避免错误
2.2 核心组件
ToastManager(业务层 API):core/ui/.../snackbar/ToastManager.kt
提供 showSuccess、showError、showInfo、showWarning、showMessage 方法。可在线程安全的任何上下文调用,内部自动切换到 Main 线程。
ToastHost(UI 层组件):core/ui/.../snackbar/ToastHost.kt
Composable 组件,黑色半透明背景白色文字,默认 1 秒自动隐藏,淡入淡出+缩放动画,支持居中/底部定位。
2.3 使用指南
配置(仅一次):在 MainActivity.kt 根布局中添加 ToastHost。
调用范式:
| 场景 | 调用方式 |
|---|---|
| ViewModel 异步操作 | EventBus.emit(AppEvent.ShowToast(...)) |
| Composable 本地提示(非异步) | ToastManager.showWarning(...) |
| 工具类 | scope.launch { EventBus.emit(AppEvent.ShowToast(...)) } |
2.4 错误处理三层架构
API响应 → Layer1 Repository(safeApiCall) → Resource<T>
→ Layer2 ViewModel(区分成功/失败) → StateFlow + ShowToast
→ Layer3 UI(ToastHost)2.5 ViewModel 核心范式
kotlin
fun someAction() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
when (val result = repository.someApi()) {
is Resource.Success -> {
_uiState.value = _uiState.value.copy(data = result.data, isLoading = false)
EventBus.emit(AppEvent.ShowToast("操作成功"))
}
is Resource.Error -> {
_uiState.value = _uiState.value.copy(isLoading = false)
EventBus.emit(AppEvent.ShowToast(result.message ?: "操作失败"))
onFinish() // 弹窗操作时必须调用以关闭弹窗
}
}
}
}关键规则:
Resource.Error分支必须做两件事:isLoading = false+EventBus.emit(ShowToast)- 弹窗操作错误分支也必须回调
onFinish()关闭弹窗 - 成功操作清理表单临时状态 + 发 EventBus 刷新事件
- UI 侧回调参数应有默认值(
= {}或= Unit)
2.6 前端预校验(配额 + 权限)
前端校验可选,后端校验必须。配置未加载时跳过前端校验。
3. 统一跨界面通讯
模块:core:common(EventBus.kt)
3.1 概述
基于 Kotlin Coroutines SharedFlow(replay=0) 实现的全局事件总线单例。类型安全(sealed class),协程支持。
3.2 核心架构
kotlin
object EventBus {
private val _events = MutableSharedFlow<AppEvent>(replay = 0)
val events: SharedFlow<AppEvent> = _events
suspend fun emit(event: AppEvent) { _events.emit(event) }
}
sealed class AppEvent {
data object ExitMultiSelectMode : AppEvent()
data object QrCodeListRefresh : AppEvent()
data object SyncCompleted : AppEvent()
data object AccountSwitched : AppEvent()
data object Logout : AppEvent()
data class ShowSnackbar(val message: String) : AppEvent()
data class ShowToast(val message: String) : AppEvent()
data class NotificationUpdate(val unreadCount: Int) : AppEvent()
}3.3 发送范式
所有 EventBus.emit(...) 在 ViewModel 内 viewModelScope.launch { ... } 调用。Composable 端禁止直接 emit。
例外:工具类(ToastManager、AuthInterceptor、NotificationWebSocketManager)在自有 CoroutineScope 内 emit 允许。
3.4 订阅范式
- 范式 A(ViewModel 订阅):页面级业务事件(
QrCodeListRefresh、AccountSwitched),在 ViewModelinit中collect - 范式 B(Composable 根订阅):全局提示类事件(
ShowToast、Logout),仅在MainActivity/AppNavHost中订阅。禁止在普通 Screen 中订阅提示类事件
3.5 已定义事件类型
| 事件 | 参数 | 用途 | 订阅位置 |
|---|---|---|---|
ExitMultiSelectMode | 无 | 退出多选模式 | 列表页 |
QrCodeListRefresh | 无 | 刷新二维码列表 | ViewModel |
SyncCompleted | 无 | 同步完成 | — |
AccountSwitched | 无 | 账号切换 | HomeViewModel |
Logout | 无 | 退出登录 | AppNavHost |
ShowSnackbar | message | Snackbar | AppNavHost |
ShowToast | message | Toast | MainActivity |
NotificationUpdate | unreadCount | 未读通知数 | ProfileViewModel |
4. 表单临时状态管理与跨页面数据传递
4.1 概述
FormStateManager 是全局单例的临时状态管理器,存储在内存中,支持跨导航保持状态。
两种策略:
- 策略 A(读后即清):跨页触发型种子/一次性回传,使用
readFieldOrNull,读后立即clearFormState - 策略 B(等通知销毁):业务表单草稿/长生命周期数据,使用
getFormData/saveFormData,保存/取消时 ViewModel 统一清理
4.2 核心组件
FormStateManager:saveField、saveFields、getField、getFormState、hasFormState、clearFormState、clearAll、saveFormData(泛型)、getFormData(泛型)、readFieldOrNull
FormIdGenerator:所有 formId 必须通过 FormIdGenerator.xxx() 生成,禁止字符串硬编码。
FormSerializable 接口:
kotlin
interface FormSerializable {
fun serialize(): Map<String, Any?>
}4.3 跨页面数据传递规范
路由定义无路径参数,种子通过 savedStateHandle 或 FormStateManager 传递。
| 场景 | 推荐方式 | 策略 |
|---|---|---|
| A → B 传 id、orgId 等种子参数 | NavBackStackEntry.savedStateHandle | B |
| A → B 触发型种子(仅消费一次) | FormStateManager.readFieldOrNull in LaunchedEffect | A |
| B → A 选中项回传 | FormStateManager.readFieldOrNull in LaunchedEffect | A |
| 列表筛选/排序 | ViewModel 内部 StateFlow | B |
| 编辑/创建表单草稿 | FormStateManager.getFormData/saveFormData | B |
入口保护:参数缺失时显示空视图,禁止 popBackStack()。
禁止的旧方式:
- ❌ Navigation URL 路径参数
- ❌
Bundle/bundleOf(...) - ❌ 全局单例管理器暂存参数
- ❌
Parcelable+getParcelable
4.4 使用模板
kotlin
// 策略 B(推荐 — savedStateHandle)
composable(NavRoutes.QR_CODE_DETAIL) { backStackEntry ->
val id = backStackEntry.savedStateHandle.get<Long>("id") ?: -1L
if (id == -1L) {
// 显示空视图,禁止 popBackStack
return@composable
}
QrCodeDetailScreen(qrCodeId = id, ...)
}
// 策略 A(一次性触发型)
composable(NavRoutes.SELECT_CATEGORY) {
LaunchedEffect(Unit) {
val id = formManager.readFieldOrNull<Long>(
FormIdGenerator.crossPageSeedId("qr_code_create"),
"categoryId", null
) ?: return@LaunchedEffect
viewModel.setCategory(id)
}
}4.5 表单草稿使用模板
- 定义
data class YourFormData : FormSerializable - 在
FormIdGenerator添加方法 - 添加扩展函数
- Composable 中使用:
LaunchedEffect恢复 →LaunchedEffect自动保存 → 提交成功clearFormState
5. 配额配置管理
模块:core:common(QuotaConfig.kt)
5.1 概述
QuotaConfig 是统一的配额配置管理类。纯在线模式,配置仅在内存中缓存。
核心职责:
- 内存缓存:登录后从后端加载
- 类型安全访问:枚举类型的配额访问
- 预校验优化:创建操作前进行前端预校验
- 生命周期管理:登录时自动加载,登出时自动清除
5.2 核心组件
kotlin
object QuotaConfig {
val current: QuotaLimits
val isLoaded: Boolean
fun update(newConfig: QuotaLimits)
fun clear()
fun checkUserLimit(currentCount: Int, limitType: UserLimitType): Pair<Boolean, String>
fun checkOrgLimit(currentCount: Int, limitType: OrgLimitType): Pair<Boolean, String>
}限制类型枚举:
UserLimitType:ORG / ACTIVITY / QR_CODE / QR_CATEGORY / ASSET_CATEGORYOrgLimitType:ASSET / PASS / ACTIVITY
5.3 关键原则
- ✅ 后端最终校验,前端仅为体验优化
- ✅ 静默失败:加载失败不影响登录流程
- ✅ 默认值兜底:未加载配置时使用默认值,不阻断操作
- ⚠️ 内存存储:应用重启后需重新加载
6. 图片上传规范(OSS + STS 直传)
6.1 概述
使用阿里云 OSS 存储图片,采用STS(临时凭证)直传方式,不走服务端中转。
关键原则:
- 公共读:OSS Bucket 设为公共读
- 分层处理:压缩 → 直传 → 回调通知
- 自动降级:直传失败指数退避重试(最多 3 次),退而走服务端中转
- 按业务分目录:
${FOLDER}/${userId}/${type}/${UUID}.${ext}
6.2 架构层次
用户操作(拍照/选图)→ UI 层(Composable, PickVisualMedia/TakePicture)
→ ViewModel(suspend fun uploadImage) → FileManager.uploadXxx(imageUri)
→ ImageCompressor.compressImage() 压缩
→ apiService.getStsToken() 获取 STS 凭证
→ OSSClient.putObject() 直传 OSS
→ apiService.uploadCallback() 通知服务端6.3 配置与压缩规则
kotlin
object OssConfiguration {
val ENDPOINT: String get() = Credentials.oss.endpoint
val BUCKET: String get() = Credentials.oss.bucket
val BASE_URL: String get() = Credentials.oss.baseUrl
val FOLDER: String get() = Credentials.oss.folder
const val STS_DURATION = 900
}
object ImageCompressConfig {
val AVATAR = CompressRule(800, 800, 85, 200)
val ALBUM_PHOTO = CompressRule(1920, 1920, 80, 500)
val ASSET_PHOTO = CompressRule(1280, 1280, 75, 300)
}6.4 核心模块
FileManager(唯一上传入口):uploadAvatar、uploadAssetPhoto、uploadAlbumPhoto、deleteRemotePhoto
所有方法返回 kotlin.Result<String>。新增业务上传方法遵循统一模式。
ImageCompressor:只读尺寸 → 计算 inSampleSize → 解码缩放 Bitmap → 写出临时文件 → 超出 maxSizeKB 降 quality 重试
6.5 ViewModel 使用模式
kotlin
suspend fun uploadImage(uri: Uri): String? {
return when (val result = fileManager.uploadXxxPhoto(uri)) {
is kotlin.Result<String> -> result.fold(
onSuccess = { url -> url },
onFailure = { e -> EventBus.emit(AppEvent.ShowToast(e.message ?: "上传失败")); null }
)
}
}6.6 图片选择 UI
| 场景 | Contract | 说明 |
|---|---|---|
| 相册选图 | PickVisualMedia | 推荐,无需存储权限 |
| 拍照 | TakePicture | 需 Camera 权限 |
6.7 Resource<T> vs kotlin.Result<T>
| 场景 | 使用 |
|---|---|
| API 请求(Repository → ViewModel → UI) | Resource<T> |
| 文件上传、工具类内部异常包装 | kotlin.Result<T> |
7. 界面整体风格
- 简洁、干净、直观:去除多余装饰,聚焦核心功能
- Material 3 设计语言:支持手动切换深色/浅色主题(LIGHT↔DARK 两态,默认跟随系统),首页标题栏和抽屉菜单均提供 ☀/☽ 切换入口
- 无标题栏页面:页面内容直接顶到状态栏顶部(标题栏与系统状态栏之间无间距)
- 内容输入页面:表单 / 编辑页面使用滚动容器,防止被键盘遮挡
8. 颜色
- 保持与
MaterialTheme.colorScheme一致,不要硬编码颜色值 - 不要手动指定
MaterialTheme,使用 composable 环境中的MaterialTheme.colorScheme即可
9. Typography
- 使用
MaterialTheme.typography中的文本样式 - 禁止创建新的自定义文本组件
10. 通用页面模板(核心布局规范)
所有页面必须使用 PageTemplate(位于 core/ui/PageTemplate.kt),禁止直接使用 Scaffold + TopAppBar。
10.1 页面结构
Box (fillMaxSize)
│
├─ SnackbarHost (可选,align=TopCenter)
│
└─ Column (fillMaxSize,垂直骨架)
│
├─ 2. 标题栏区域 ▸ Box (高度自适应)
│ ╰ 普通:返回按钮 + 标题 + 右侧操作按钮
│ ╰ 选中:"已选择 N 项"
│ ╰ 排序:"编辑排序模式" + 刷新按钮
│
├─ 3. 操作栏区域 ▸ Box (0~N 行,高度自适应)
│ ╰ 分类筛选 Row + 搜索框 Row
│
├─ 4. 内容区域 ▸ Box (weight(1f),占满剩余空间)
│ ╰ LazyColumn / 表单 / 相机预览 / 地图 / Canvas
│
├─ 5. 底部操作区域 ▸ Box (0~N 行,高度自适应)
│ ╰ 多选操作栏、底部按钮、相机 TabBar 等
│
└─ 6. 底部导航 ▸ Box (NavigationBar,仅部分页面需要)10.2 模板 API
kotlin
@Composable
fun PageTemplate(
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState? = null,
titleBar: @Composable BoxScope.() -> Unit = {},
topBarContent: @Composable BoxScope.() -> Unit = {},
content: @Composable BoxScope.() -> Unit,
bottomBarContent: @Composable BoxScope.() -> Unit = {},
bottomNav: @Composable BoxScope.() -> Unit = {},
)10.3 禁止项
- ❌ 禁止使用
Scaffold + TopAppBar - ❌ 禁止使用
Scaffold(topBar = {})仅为了获取 paddingValues - ❌ 禁止在外层使用
.statusBarsPadding()(由页面自行处理) - ❌ 禁止在内容区域使用
.padding(innerPadding)或.padding(padding) - ❌ 禁止在内容区域使用
ConstraintLayout作为顶层容器
10.4 三种模式适配
对于带多选、拖拽排序的页面,titleBar 适配三种状态:
| 模式 | 标题栏显示 | 底部操作区 |
|---|---|---|
| 正常 | 标题 + 右侧操作按钮 | 默认操作栏 |
| 选择 | "已选择 N 项" | 全选/取消 + 删除/置顶/移动 |
| 排序 | "编辑排序模式" + 刷新 | 完成按钮 |
10.5 使用示例
kotlin
// 带标题栏的页面
PageTemplate(
titleBar = {
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = CenterVertically) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") }
Text("标题", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f))
TextButton(onClick = { /* 保存 */ }, enabled = name.isNotBlank()) { Text("保存") }
}
},
content = {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("名称") })
}
}
)11. 其他界面规范
11.1 统一的错误处理
- 网络加载失败时显示可重试界面(如
RetryScreen),而非只显示错误文字 - 异常由
GlobalExceptionHandler统一捕获,通过 Snackbar 或 Toast 提示
11.2 统一的加载状态
- 使用
LoadingScreen组件显示加载动画 - 加载状态由 ViewModel 通过
UiState控制
11.3 列表组件
- 使用
LazyColumn或LazyRow渲染长列表 - 列表项用
Card包裹 - 空列表时使用
EmptyStateScreen组件
11.4 对话框规范
- 使用
AlertDialog或 Material 3 的Dialog - 确认删除等危险操作需要用户二次确认
11.5 底部导航
- 使用 Material 3 的
NavigationBar和NavigationBarItem - 5 个底部导航项:首页、相机、二维码、相册、组织
- 底部导航放在 PageTemplate 的
bottomNav参数中
12. 桌面小部件规范
CodeNote 当前只实现了 1 个桌面小部件:
CameraWidgetProvider(扫码快捷入口)。
12.1 适用场景
- 扫码快捷入口:1×1 图标,点击直接进入扫码页
- 暂不支持其他类型小部件
12.2 实现约束
- 使用 Android framework 原生
AppWidgetProvider,不引入 Glance - 布局使用 RemoteViews(XML layout),不支持 Compose
- 点击跳转使用
PendingIntent.getActivity(),flags 必须包含FLAG_IMMUTABLE updatePeriodMillis设为 0(不自动刷新,无数据变化)
12.3 文件清单
| 文件 | 说明 |
|---|---|
CameraWidgetProvider.kt | AppWidgetProvider 子类,点击跳转 MainActivity |
res/xml/camera_widget_info.xml | Widget provider info(minWidth/minHeight/initialLayout) |
res/layout/camera_widget_layout.xml | RemoteViews 布局(1×1 图标) |
res/drawable/ic_qr_code_scanner.xml | 矢量 icon |
12.4 注册方式
在 AndroidManifest.xml 中用 <receiver> 声明:
xml
<receiver android:name=".CameraWidgetProvider" android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider"
android:resource="@xml/camera_widget_info" />
</receiver>