用户界面层,英文叫 Presentation Layer,在大部分项目的分层架构里都被认为是“最没有技术含量”的一层。我长期做客户端和前端架构,一开始也这么想,直到某次接手一个基础设施几乎为零的老项目,花了整整三周在把堆在界面层里的业务逻辑往外搬,才意识到:这一层如果边界不清晰,整个系统的复杂度会呈指数级增长。这篇文章是我整理给团队的用户界面层技术文档内容的展开版,适合正在做移动端、桌面客户端、Web 前端的开发者,也适合想把自己的界面层从“一团乱麻”整理成“层层分明”的架构师。我会从职责边界、协作方式、状态建模、性能优化和测试策略五个方面去讲,最后给出一份可以直接拿去评审的落地清单。
1. 用户界面层到底是什么:一个被低估的架构边界
1.1 我给用户界面层画出的职责清单
用户界面层并不是“一堆控件放进布局文件”那么简单。按我目前的实践,这一层要执行的职责至少包括以下六类:
- 展示模型映射:把领域模型转换成适合当前界面展示的模型。例如后端返回的毫秒时间戳,在 UI 层显示成“2025-02-14 14:30”,或者把 0.34 渲染成“34%”,这些都属于展示格式化,是 UI 层自己的事。
- 用户事件翻译:监听点击、输入、手势、键盘事件,把它们翻译成有业务含义的“意图”或“命令”,再交给下层处理。比如按钮 onClick 里不是直接执行支付,而是调用
checkout()这个方法。 - 界面反馈状态:管理加载中、成功、失败、空态、网络异常等页面状态,决定当前屏幕渲染什么。这是 UI 层最容易写乱的部分。
- 导航与路由:处理页面跳转、Tab 切换、Dialog 显示、返回键逻辑、深链解析。
- 本地输入校验:非空校验、长度限制、邮箱格式、密码二次确认等。注意这里只是“格式类”校验,真正“是否允许下单”这类规则不是 UI 层该负责的。
- 轻量的交互状态:搜索框防抖、列表展开收起、滚动位置记忆、主题切换等,这些状态生命周期较短,且只服务于界面本身。
对应地,什么东西绝对不能进用户界面层?我有一条底线:不允许 UI 层直接发起网络请求、操作数据库、读取全局 Token 拼接签名、计算折扣价格、判断用户权限。这些事一旦出现在界面代码里,那它就不再是用户界面层,而是一个披着 UI 外衣的业务逻辑层。
1.2 一个典型的反面教训:我把所有东西都塞进了界面层
好几年前某电商项目,下单页面的 Activity 里写了几乎一切:服务端地址判断、购物车余额校验、Token 读取、图片上传、支付签名拼接,甚至还有支付宝支付回调后的 resultCode 判断。最初一个月开发速度极快,因为所有代码都在同一个类里,不需要想接口,不需要做抽象。
三个月后需求变了:支付方式从“余额 + 支付宝”改成“余额 + 银行卡”,我需要改动的是同一个方法里的调用顺序和回调处理。结果是支付失败、重复创建订单、余额未扣减,测试回了 21 个 bug。那一次之后我才彻底明白,代码堆在 UI 层,开发速度是先快后慢,到后面不是“能不能改”,而是“敢不敢改”。后来重构才把签名逻辑、订单状态机全部下沉到业务层,界面层只保留按钮 loading 状态和错误提示。同样的需求变更,改动量从十几个文件縮到两个文件。
1.3 为什么说“UI 层只是控件的堆叠”是错误的
如果只是控件堆叠,那 UI 层确实没有文档价值。但实际上,现代应用的 UI 层内部要处理的东西远比控件本身多:视图树如何组织、状态流如何单向流转、事件流如何与状态合并、依赖从哪注入、窗口尺寸和横竖屏怎么适配、深链参数怎么解析、主题怎么切换。这些都属于 UI 层的“内政”。一个健康的用户界面层,真正讨论的是展示、交互和状态之间的协调关系,而不只是放几个按钮和输入框。
2. 用户界面层与业务层、数据层的协作:依赖方向决定架构健康度
2.1 依赖必须朝向内核:用一句话解释依赖倒置
用户界面层在分层架构中处于最外层,它必然依赖内层的业务层或数据层的抽象。但反过来,内层永远不能依赖 UI 层。这条规则在代码上的落地手段是:UI 层依赖接口,而不是具体类。
举个例子,用户详情页需要显示用户资料。UI 层看到的接口是:
interface UserRepository { suspend fun getUser(id: String): User }界面层只拿到一个UserRepository,它不关心实现是走 HTTP、读本地缓存,还是返回一份 Mock 数据。这样做最大的收益是:测试时可以传一个假实现,用户详情页不需要启动服务器也能跑。
如果把接口放在内层、把实现类放在数据层的仓库里,就形成了依赖倒置。界面层需要什么能力,就去依赖接口;至于谁提供实现,是构造阶段的事。
2.2 MVVM、MVP、MVI 该选谁:不吹不黑的选择建议
界面层与内外层之间有很多种协作模式,我实际用过的有四种,列成表格会看得比较清楚:
| 模式 | 核心思想 | 可测试性 | 适用场景 |
|---|---|---|---|
| MVC | View 持有 Model,Controller 更新 Model | 最差 | 服务端渲染、简单页面 |
| MVP | Presenter 持有 View 接口,双向调用 | 中 | 老项目重构、页面逻辑复杂的场合 |
| MVVM | ViewModel 不依赖 View,通过数据绑定或状态流通知界面 | 好 | Android、WPF、Vue/React 等现代框架 |
| MVI | 所有用户事件都包装成 Intent,状态通过纯函数归约生成 | 最好 | 交互路径复杂、需要状态可回放/可追溯的场景 |
我的选择建议是:新项目从 MVVM 起步,状态变复杂后再往 MVI 演进。不要为了新而新,MVP 和 MVC 也有存活的价值,特别是小型页面和内部工具。
2.3 采用单向数据流之后的界面层形态
在移动端和前端,我最近几年最推荐的是单向数据流(UDF)。它的基本思路是:
- UI 发出事件(Event / Intent);
- ViewModel 接收事件,调用业务层的用例;
- 业务层返回结果后,ViewModel 生成新的不可变状态;
- UI 观察状态并重新渲染。
这个循环保证了数据只有一个入口和一个出口,UI 层永远不会直接修改状态对象。调试时只要记录下状态序列,就能还原出用户操作的完整路径。下面是一个搜索页 ViewModel 的简化示例:
sealed class SearchIntent { data class ChangeQuery(val text: String) : SearchIntent() object Submit : SearchIntent() } class SearchViewModel( private val searchProducts: SearchProductsUseCase ) { private val _uiState = MutableStateFlow(SearchUiState()) val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow() fun dispatch(intent: SearchIntent) { when (intent) { is SearchIntent.ChangeQuery -> { _uiState.update { it.copy(query = intent.text) } } SearchIntent.Submit -> { if (_uiState.value.query.isBlank()) return viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val result = searchProducts(SearchQuery(_uiState.value.query)) _uiState.update { it.copy(isLoading = false, items = result.items) } } } } } }界面层这里只做三件事:把输入框的文本包装成ChangeQuery,点击搜索包装成Submit,然后观察_uiState渲染列表。
2.4 依赖注入:没有框架时怎么手工注入
很多开发者一听到依赖注入就想到 Dagger、Hilt、Spring 这种大框架。但实际上,依赖注入的核心不是框架,而是“把依赖从外部传进来”。如果项目不复杂,构造函数传参就是最简单的注入方式:
class SearchScreenViewModel( private val repository: UserRepository )问题是,Android 的 Activity、Compose 的可组合函数、SwiftUI 的 View,这些 UI 入口由系统创建,不能直接传参。这时常见的做法是引入服务定位器或视图模型工厂。我的经验是:先尝试把依赖尽量上提,在 App 启动时完成组装;被组装出来的 ViewModel 再通过工厂提供给 UI 层。一个轻量的ViewModelProvider.Factory就足够,不需要注解处理和编译器生成的复杂度。
某些团队为了 DI 而 DI,结果编译时间翻倍、运行期出现一堆看不到来源的代理对象。记住,注入的核心目的是可替换和可测试,不是“用上某个工具的仪式感”。
3. 视图状态建模:用密封状态替代散装布尔变量
3.1 散装布尔状态是界面层失控的第一个信号
我每次接手新项目的第一步,就是搜索界面代码里有没有这种写法:
var isLoading = false var isError = false var errorMessage = "" var isEmpty = false var data: List<Item>? = null这种“散装状态”最大的问题是组合爆炸。isLoading = true和isError = true可以同时存在,UI 根本不知道该渲染加载中还是错误页。更致命的是,界面代码里会大量出现if (!isLoading && !isError && data != null)这种条件,分支越来越多,最后没人理得清页面到底有多少种显示形态。
3.2 用密封类整理页面状态:代码示例与收益
更好的做法是把页面状态建模成一套密封类(或者 TypeScript 里的可辨识联合):
sealed class SearchUiState { object Idle : SearchUiState() object Loading : SearchUiState() data class Success( val items: List<ProductItem>, val pagination: Pagination? ) : SearchUiState() data class Error( val message: String, val retryable: Boolean ) : SearchUiState() }这样每个状态都是互斥且完整的。UI 层用when (state)穷举分支时,编译器会强制你处理所有情况。少处理一个 Idle,编译直接告诉你。而且因为状态类是不可变数据类,ViewModel 只能通过copy()生成新状态,UI 层永远无法在渲染过程中“顺手”改数据。
收益是明显的:状态流转路径从“无数条”收敛成“几条明确路径”;测试时针对每个状态构造对应对象,断言 UI 行为变得非常简单。
3.3 局部状态与全局状态:我建议的存放位置
用户界面层内部还会遇到“状态放哪里”的问题。我按生命周期和共享程度来区分:
- 局部状态:搜索框输入、弹窗开关、列表滚动位置、当前 Tab 选中的下标。这些状态只服务于当前页面,就放在页面自身的 ViewModel 或组件内部,没必要提升到全局。
- 跨页面共享的 UI 状态:深色模式、语言、通知开关、上次浏览位置。这些可以放到全局状态管理里。
- 业务数据状态:用户余额、购物车商品、订单状态。这些不应该是 UI 层维护的状态,而应当来自业务层,UI 层只是读取和展示。
很多人犯的错是把用户购物车硬塞进 Vuex / Redux / Bloc,最后业务逻辑都写在 Store 的 action 里,界面层反而变成了一个纯渲染壳,这不叫分层,只是把问题从 UI 层搬到了状态管理层。全局状态管理工具的定位,是 UI 层跨页面协作的“缓存和同步设施”,不是业务仓库。
3.4 竞态处理:搜索请求比响应慢的经典坑
状态建模还解决不了一个经典问题:竞态。用户输入“苹果”后立刻改成“香蕉”,第一次请求的网络响应比第二次慢,结果“苹果”的结果覆盖了“香蕉”的结果,页面显示的内容和搜索框内容不一致。
解决思路不算复杂,我常用请求序号:
class SearchViewModel { private var requestSequence = 0 fun onSearch(query: String) { val currentRequest = ++requestSequence viewModelScope.launch { val result = repository.search(query) if (currentRequest == requestSequence) { _uiState.update { it.copy(items = result.items) } } } } }或者用协程的flatMapLatest/switchMap,让新的响应自动取消旧的。这个细节在用户界面层技术文档里值得单独写一节,因为竞态引发的问题非常隐蔽,且一旦出现,看起来像是“缓存没刷新”或者“数据源返回错了”。
4. 组件组织与渲染优化:让用户界面层不拖后腿
4.1 组件粒度:按复用频率抽取,而不是按文件数量抽取
界面层的组件化容易被走成两个极端。一个极端是把所有东西都拆成小组件,结果出现几十个只有一层 wrapper 的文件,props 参数比内容还多;另一个极端是每个页面一个巨型文件,内部几百行,无法拆分。我吃过第一个极端的亏,一个页面为了“组件化”,拆了 20 多个子组件,改一个全局 theme 变量要跨十几个文件。
我现在遵循的标准是:一个组件至少满足以下一条才值得抽取——在三个以上地方被复用,或者它包含一屏以上的独立交互逻辑。抽取时,组件接口要尽量小,数据从外部传入,内部只保留局部 UI 状态。页面级组件和通用原子组件之间,还应该有一层“业务组合组件”,比如一个商品卡片、一个订单状态条、一个筛选面板。这一层最容易被忽略,但也最影响复用性。
4.2 列表渲染优化的三个实测手段
长列表是界面层性能问题的高发区,我有三个实测有效的手段:
- 虚拟化列表:Android 的 RecyclerView / LazyColumn,iOS 的 UITableView / UICollectionView,Web 端的 react-window,Flutter 的 ListView.builder。它们只在可视区域创建 item,滑动时回收复用,能支撑上千条数据。
- 不可变数据配合 Diff 算法:每次更新时不是整个列表替换,而是计算最小差异。配合不可变状态,Diff 才能可靠工作。数据没变时返回同一个引用,避免无意义的重渲染。
- 图片按需加载和压缩:列表里的图片不能直接加载原图,尤其是服务器下发的几 MB 大图。我通常在接口层就申请好缩略图 URL,必要时再加图片缓存库,只保留当前屏幕周围的图片占用内存。
举一个我实际优化过的案例:一个资讯长列表,单页 50 条,每条都带一张大图,低端机上切换 Tab 滚动卡顿。优化三件事——分页改成 6 条、图片用缩略图、列表用 Diff + 不可变数据刷新。结果滚动帧率从 20fps 提升到 55fps,内存占用下降 40%。
4.3 防止不必要的重组:从数据源头控制引用
在 Compose、SwiftUI、React 这类响应式框架里,无意义的“重渲染”是性能杀手。最常见的写法是:
_uiState.update { it.copy(items = loadItems()) }每次进入这个分支都会创建新的 List 对象,即使 items 内容完全一样,也会触发界面重组。严格的写法应该是先判断新数据和旧数据是否相等,不相等才更新;或者用distinctUntilChanged、shouldComponentUpdate、memo selector 等机制截断更新。
当然,不要过度追求“零重组”。一个页面里少量无意义的文本重组,代价可能只有几毫秒;但如果在滑动列表中每次帧都创建 1000 个 item 对象,那一定会卡顿。把性能分析工具打开,先看热点,再决定要不要优化,而不是凭感觉到处加 memo。
5. 可测试性是用户界面层技术文档最重要的章节
5.1 ViewModel / Controller 单元测试:覆盖状态流转
用户界面层最值得做的测试,不是 UI 自动化的“点按钮”,而是对 ViewModel 或状态容器的单元测试。因为这一部分承载了 UI 层的大部分可变逻辑:状态怎么从 Loading 变成 Success,错误信息怎样被填充,竞态怎么处理。这些逻辑都可以脱离真实设备和浏览器来测。
拿搜索 ViewModel 为例,只要注入一个假 UseCase:
class SearchViewModelTest { private val fakeRepo = FakeSearchRepository( result = Result(listOf(productA)) ) @Test fun `加载成功后状态变为Success`() = runTest { val viewModel = SearchViewModel(fakeRepo) viewModel.onSearch("keyboard") assertTrue(viewModel.uiState.value is SearchUiState.Success) } }这里不需要启动 Activity,不需要服务器,也不依赖网络。测试运行速度快,稳定性高。我实践下来,ViewModel 里 80% 的状态流转逻辑都可以这样覆盖,而成本比 UI 自动化测试低一个数量级。
5.2 写组件测试的三条经验
真正需要组件测试的地方,是确认“渲染结果符合用户预期”。这里我踩过不少坑,总结三条经验:
- 测行为,不测样式:不要断言某个颜色的 class 名、内边距、字体大小,这些改动频繁且不影响功能。而应该断言“按钮是否存在、点击后是否触发回调、输入框内容是否更新”。
- 选择语义化选择器:测试里用可访问性标签、role、placeholder 等语义化的标识,而不是 DOM 结构或者私有属性。例如用
screen.getByLabelText('搜索输入框')而不是document.querySelector('.search-input')。 - 测试完整交互路径:不要只测“渲染后列表有几个 item”,测“输入关键词 -> 点击搜索 -> 列表出现结果 -> 点击结果跳转详情”。这样的测试才真正保护用户场景。
test("搜索后可看到结果", async () => { render(<SearchScreen />); fireEvent.change(screen.getByLabelText("搜索输入框"), { target: { value: "ai" }, }); fireEvent.click(screen.getByRole("button", { name: "搜索" })); expect(await screen.findByText("AI 开源课程")).toBeInTheDocument(); });5.3 测试金字塔:我应该写多少界面层测试
界面层的测试分配,我沿用经典金字塔:单元测试 70%,组件测试 20%,端到端测试 10%。具体到用户界面层,单元测试主要是 ViewModel / 状态归约函数 / 展示格式化函数;组件测试覆盖几个核心通用组件和业务组合组件;端到端只跑主流程和最容易回归的路径,比如“登录 -> 首页 -> 详情 -> 下单”。
不要盲目追覆盖率。我曾经为了让某个项目覆盖率达标,写了大量“点击按钮 -> 断言文案出现”的测试,结果产品把文案改了一轮,全部测试翻车。后来删掉一大半没价值的测试,才恢复健康。UI 层的测试重点永远是“逻辑稳定”,而不是“文案固定”。
6. 用户界面层技术文档的落地清单:评审和自检
6.1 设计评审时我都会问的十个问题
把前面讲的内容浓缩成交付前的自检清单,每次提测或评审时直接拿来过一遍:
- 新功能的业务规则有没有被塞进 UI 组件、Activity、ViewController 或 View 内部?
- UI 层是否直接依赖了网络库、数据库类的具体实现?
- 页面状态是否建模成了一套不可变的完整状态,而不是散装布尔变量?
- 异步操作(搜索、提交、刷新)出现“旧响应覆盖新响应”时有没有处理?
- 加载中、成功、空态、失败这四种状态是否都有明确的渲染分支?
- 跨页面共享的 UI 状态是否已经被提升到全局,而不是在多个页面重复维护?
- 页面文案是否做到了国际化/可配置化?改一个促销语是否必须发版?
- 依赖是通过外部注入,还是内部
new出来的? - 核心状态流转链路是否有单元测试覆盖?
- UI 层是否能在不启动后端服务的前提下,用 Mock 数据独立运行和测试?
这十个问题里只要有三个以上回答“没有”,我就知道这个页面的用户界面层架构大概率开始失控了。
6.2 两条铁律与我的最终体会
规则再多,最后落到代码上的铁律只有两条:
- 用户界面层只做“展示与反馈”,不做“业务决策”。一切需要判断“能不能、该不该、是否允许”的逻辑,都要向业务层提问,而不是自己拍板。
- 用户界面层必须可以在不启动后端服务的前提下独立运行。当开发们每天需要开着代理、连内网、等服务器才能打开界面时,说明这一层已经和外部环境绑死,架构一定是错误的。
这几条看起来都是常识,但我在无数个项目里都见过违反它们的代码。每次重构的起点,永远不是换一个酷炫的新框架,而是重新划分一遍职责边界。把这些整理成技术文档放在团队里,最大的作用不是约束别人,而是提醒自己:用户界面层的价值,从来不是“画了多少页面”,而是“让业务变化在这个系统里可以安全地发生”。