浅谈 GUI 应用开发

技术

自接触编程起,我一直挺向往 GUI 应用开发的方向,妄想有机会可以做出像千千静听那样能给人一种“哇塞”感觉的软件。自初中开始接触 Adobe Flash / Air 到 HTML5 摸到一些皮毛,再到大学时写 JavaScript 和 React 逐渐上道,满打满算经历了一轮技术浪潮的冲刷,外加几年的实习、工作经历一路走来,也让我有了不少的思考和感悟。

关于 Web 与 GUI 应用开发方面的想法,过去我在 Blog 写下了部分,比如 Web App 发展史单页应用的交互模式R&D 工程师的角色定位 等等。当前我自 Web 开发切入工作也近五年,自觉在 GUI 应用开发方面有了一定的认识,差不多可以写一篇东西展开讲讲,也借此理一理思路,让这件事变得更清晰简单一些。

在聊 GUI 之前需要明确的是,软件这一事物是怎么与外界互动的,在现实中承担着一个什么样的角色。在我看来,计算机世界与现实世界有机结合,才有助于我们找到软件应有的迭代方向。一个软件的使命,在于在计算机世界开辟一个特有的环境,承接住现实的业务流程;在特定的设计下,借助计算机的力量、把一些现实难以完成的流程变得更加顺畅,把适合机械化的部分交给机器,创造性的部分留给人工。

至于哪一部分留给机器,哪些交还给人工,更多属于产品经理、设计师层面思考的话题,这里会更侧重于工程师视角的实现层面。我想可以从 状态管理生命周期交互模式屏幕适配,以及 研发流程 等几方面去展开讨论,主打一个抛砖引玉。

状态管理

状态管理可以说是一个 App 实现的核心部分,某种意义上来说,计算机本身就是一种状态机,一个 App 内部也是,只是相比于机器而言 scope 更小一些。计算机从 01 两种基础状态开始,慢慢发展出许多复杂的数据结构和概念,让人眼花缭乱。人的精力是有限的,很多细节其实无暇顾及,如何在有限精力的局限下,让分散各处的状态变化变得可控,应该是状态管理关注的核心目标。

面对眼花缭乱的 App 状态,需要的是抓住最关键的部分。在 React / Vue 等 UI 库流行的当下,这个关键部分其实已经非常符合直觉,UI = f(state) 的深入人心,意味着我们只需关注那些会引起 UI 变化的 state 部分,其余交给 UI 库,CSS 样式(Web)本身去处理,或自己写写各种样式 Hack 逻辑。

当然,这里的 state 的组成可以很复杂,但整体来说,我想可以拆分为“核心状态”,以及支撑核心状态的“附加状态”两部分。举个例子,在做 B/S 相关的业务时,UI 变化很多时候来自后台数据的更新,用的最多的大概是 loading, success, error 这几种 case。对于这种简单的情况,一般会用 isLoadinghasError 等方式组合实现。更清晰的是将其定义为 Enum,比如说 LoadingStatus,把所有可能性一一列出来。

enum LoadingStatus {
  INIT,
  LOADING,
  SUCCESS,
  ERROR,
}

interface PageState {
  status: LoadingStatus;
  pageData: {};
  errorMessage: '';
}

然后找个地方存一下核心状态和附加状态,再处理状态转移需要的 UI、数据层面的同步逻辑,再在合适的地方驱动状态的流转,一个基本的 GUI 应用就这么成型了。

const pageState: PageState = {
  status: LoadingStatus.INIT,
  pageData: {},
  errorMessage: '',
};

function setPageState(params: {
  nextStatus: LoadingStatus,
  payload: Partial<Pick<PageState, 'pageData' | 'errorMessage'>>,
}) {
  pageState = {
    status: params.nextStatus,
    ...params.payload,
  };
  // 可以是 React / Vue 以及各种 balabala...
  syncWithUI(pageState);
}

对于传统的网页前端而言,以上 LoadingStatus 的关键状态,已能覆盖绝大多数的业务场景,工作量更多集中在 UI 样式的还原,页面结构的规划,与后端开发的联动等方面。当我们的目光上升到 App 的层面,需要关注的状态自然不会这么简单,应对复杂的 App 状态,对应的技术方案自然也会复杂很多,相对应的也是前端百花齐放的状态管理方案。

在 Web 生态的视角,最直接的状态管理,大概还是 “组件化” 拆分代码,对于大型的复杂 App ,采取一种“分而治之”的策略,将状态分散在各个子组件,组件内局部状态自成一套。某种意义上有种“大水漫灌”的感觉,通过上层组件层层向下传值的方式去同步状态的变化,依次确保子组件能同步最新的状态。然后再通过类似 useEffect 的手段,识别状态的变化,在做一些对应的副作用动作。

在这样的场景下,各类状态管理库,定位上更多是一种针对不同场景定制的“代码拆分”的艺术,以支撑 App 的迭代、测试等各种需要。譬如 React 自带的 useState / useReducer + Context,以及类似 reduxzustandjotai 等等第三方外置状态管理、共享方案。

生命周期

值得思考的一点是,是否所有的业务场景都适合这样的“大水漫灌”模型?在 React 开发中常见一个现象是 useEffect 的滥用,导致 App 代码复杂后难以 debug 的问题。

想象一个软件拥有自己的灵魂,伴随它的一整个生命周期,大概分为三个阶段:

  1. 启动阶段:加载代码、状态初始化
  2. 工作阶段:与操作系统、网络、用户、其他软件等打交道
  3. 退出阶段:清理状态,释放资源,然后被回收

从 React App 角度来说,它也有它自己的生命周期(想必各位面试八股文选手早已耳熟能详),核心在于组件渲染 mountupdateunmount 的过程,以及各种用户事件的处理函数。和外界的交互,主要通过 updateuseEffect 和用户输入、后台接口等来打交道。基于一个个小组件拼装形成成大组件,进而构成了整个 App。

很明显,一个 App 所涉及的交互流程,就不仅仅单纯像命令行 CLI 程序那样只有一根筋的 input / output / error,在生命周期中的每一个动作,大都会牵扯到一条新的流程,有它自己的 inputoutput 以及 error。随着 App 的复杂,流程互相交织,流程所对应的代码逻辑足够简洁清晰,就应该成为代码设计的一个大原则。

概括而言,一条流程有几个生命周期:

  1. 流程的启动,考虑流程的触发源头
  2. 流程的运转,各种不同的逻辑判断,启动新的流程等
  3. 流程的终止,包括正常结束,以及异常终止的情况

在流程启动的视角,它的触发来源并不总是很清晰。比如说启动与退出,它们发生的通常比较自然,Web App 的启动,发生在 HTML 代码加载与渲染之间,退出则伴随着很多意外情况(浏览器标签关闭、系统死机、断电等)。对于 React 的组件来说,许多流程发源于 useEffect 订阅的 props 等状态变化,属于一种 “大水漫灌” 的背后隐含的事件订阅的模型。当然很多时候类似的流程变化我们没有太大必要“精细化浇灌”、单独去定义订阅和同步的逻辑,所以交给 “大水漫灌” 是更合适的选择。

进入流程运转的过程,需要明确的一点是,涉及到核心业务逻辑的 case,有必要避免在流程中间穿插“大水漫灌”的逻辑。一条业务流程,遇到这种汇集了多种不同流程的步骤,遇到问题的 debug 难度也会直线上升。就像是人走在十字路口的迷茫感,缺少背景信息的参考,你不知道下一步会往哪走,只能误打误撞一个个试错。遇到组件相互通信的场景,有时候单独定义新的 event bus 去做 “精细化浇灌”,要比走全局 UI 渲染的“大水漫灌”逻辑更简单直接和可靠。

流程终止的场景常常容易被忽略,尤其是在当下大多数团队只满足于“能跑就行”的背景下。如果真心想要把软件做得足够完善,需要更多地从用户视角去思考异常处理。一条流程中途遇到异常导致的中断,这个中断会导致什么影响?需要用户做出什么样的行动,研发视角又怎么监测到这个问题,背后是一整套错误 catch、上升的策略。用户能做的行动通常是 “重试” -> “采取备选方案” -> “寻求研发支持” 的过程;研发的视角则需要做好错误的分级、上报和告警机制,在用户意识到问题前能提前发现并修复,在重要流程适当设计一些降级策略,避免阻塞用户的使用。

到这里,我们对流程有了一个相对清晰的定义。随着软件复杂度的提高,代码迭代可能会影响某些流程而未被发现,进而产生新的 bug,需要有对应的机制去保障流程不会随着软件更新而中断,也是软件测试核心关注的点。在研发的视角,也可以针对一些比较重要的流程,写写单元测试,减少这里反复验证的人力损耗,覆盖单纯从黑盒视角的功能测试难以覆盖的极端 case。

交互模式

上一节聊到了流程的概念,这里关注的是依托浏览器的 Web App 与平台原生 App 运行环境的差异,重点在于 Web App 与 Mobile App 交互模式的差别,以及可能被忽视的细节。

两者一个大的差异点在于 Web App 的工作模式更像是一个单 Screen 的“画布”,而 Mobile App 是多个 Screen 堆叠起来的 Screen Stack。因为 Web 本来是文档渲染起家,在代码环境提供了更多预设能力的画布模型,也就是 DOM,我们可以通过 React / Vue 等 UI 库方便做 state 与 DOM 的相互映射。浏览器同时提供了 History API,开放了 History Stack 以及地址栏给外部操作。浏览器通过 History 跳转到别的页面,相当于整个页面直接刷新,原来的 UI 元素都会重新渲染甚至销毁。并且 History Stack 的每一层都可以保存一份 state,因此如果想要把交互做的精细完善,还需要考虑清楚这个 Web App 应如何管理和应对不同的 History Stack,是 push,还是 replace,以及用户如果直接从地址栏进入页面,又应该如何处理。

Mobile App 的交互和 Web 有些不同,它会涉及到不同页面同时层叠的 UI(微信小程序也是类似交互),因此需要考虑 Screen Stack 的维护,以及多 Screen 并存时可能的互相通信。在一些特殊场景,还需要考虑类似 Web 直接输入地址打开某页面的模式,当用户通过 Deep link / Universal Link 打开 App,打开用户想打开的目标页面,或给出合理反馈。

浏览器与 Native App 还有一个差异点在于窗口之外的 UI 交互,浏览器在处理拖拽、浮层、上下文菜单等能力会有缺失,开发成本考虑,大部分时候会自行在 DOM 上模拟实现;要实现 Native App 的效果,还需要操作系统的参与,这个细节就会涉及更多的 Native API 的调用,就得单独考虑了。

屏幕适配

另一我想说的点是 UI 在不同屏幕、窗口尺寸的适配,原则上应避免简单粗暴的按窗口宽度缩放。在一个网页开发者的视角,思考模式通常更倾向于直接缩放页面元素,用 rem 和浏览器宽度动态控制,或者用 vw 等单位处理(一个典型例子是微信小程序的 rpx 单位),这样的操作,在预期之外的屏幕尺寸上可能会面临一些奇怪表现,比如说在平板上大而难看的字体;或者缩放后字体小于浏览器最小 12px 的限制进而影响布局(更新:Chrome 118 后,默认最小字号放开了),或者因为字体大小舍入原因导致字体发虚等问题。

比较好的做法是统一用 px 单位,并借助 flex 弹性布局的灵活性和 Media Query,老实做好设计层面的适配,在不同的屏幕尺寸有所兜底。缩放和屏幕适配问题,则交给 浏览器 / 宿主平台 处理,现在的浏览器缩放也是基于 px 的,不会再动字体大小。如果需要考虑文本的单独缩放,需要的是研发与设计配合,专门针对其去做适配和处理,引导用户使用产品提供的方案,而非仅凭猜测行动,让用户去动那些充满不确定性的字体大小设置。一个参考:R.I.P. REM, Viva CSS Reference Pixel! | Mind The Shift,关于用 px 还是 rem,也是一个 Web 领域的争议话题。

还有一个比较容易忽视的细节是关于 UI 中作为容器的盒子的尺寸稳定性,尤其是对滚动区域的把控上,Web 开发者常直接依赖添加 overflow: autodiv 盒子,在内容超过最大高度时,浏览器会自动生成滚动条,而这个默认滚动条会挤占被滚动区域的宽度,某些情况下会发生抖动影响用户体验。比较好的做法是显式地指定一个 ScrollView 的角色,在最外层就定义好盒子的宽高与滚动状态,让元素可以相对于它来布局,保持外层容器的稳定性。针对被滚动的区域,可以单独包装一层,定好宽高,避免其被滚动条影响。这么一波操作下来,即使要再兼容适配 safe area、沉浸式刘海等特殊场景,也会非常方便。

研发流程

明确了 GUI 开发涉及到的一些生命周期和交互模式,对应研发流程的关注点也需要做一些调整,在我去年的 总结 有提到,这里类似于 “用户故事地图” 的思维模式。前文说到的“流程”,其实和这里产品经理视角的“用户故事”,设计视角的 “User Flow”,还有 QA 视角的“功能用例”可以说是指向了一个东西,让我们姑且就把它称为 “Flow” 吧。

开发的核心关注点恰好也是在这个 Flow 上面。首先得评估技术方案,明确改动的目标状态,当下状态,改动策略等;在开发任务的前期,铺垫好基本的依赖之后,尽可能早些走通这个 Flow,提前暴露出潜在较大的风险;接下来才是如何完善这个 Flow,让用户操作走的更顺体验更好。

这里大概可以形成一个 Roadmap:

  1. 评估技术方案,改动影响
  2. 开发走通 MVP,形成一个草稿状态的 Demo
  3. 产品、设计介入,拉不同角色联调,完善,产品化这个 Demo
  4. 产品、设计、QA 介入,进一步覆盖 Edge Case 和打磨细节,提升软件在各种复杂环境的鲁棒性和兼容性

从 Flow 这个视角来看,所有的参与方的目标是非常一致的,可以尽可能减少沟通上的拉扯和损耗,开发贯穿于其始终,起到一个承上启下的作用,因此对于研发工程师而言,这种对 Flow 的目标感就非常重要,毕竟很难有一个角色可以完整关注到从想法到实现的流程,乃至于每个设计细节的落地。当然,这里的 Roadmap 会更侧重于需求开发阶段,至于其他阶段如何行动,在我之前写的关于 R&D Engineer 的文也有 聊到,关于一名研发工程师在软件开发运维工作中的一个角色定位。

以上更多是个目标,从执行角度来看,要想达成更高的交付质量,总结起来大概有三个值得注意的点:

  1. 放下完美主义,抓大放小,在有限的资源下尽量做到全局最优。开发功能的最初,更大的目标在于核心 Flow 的走通,就不需要过早去关注一些比较边界的细节,细节可以放到需求的后期,或者是上线后再做一些 Follow up 优化版本;
  2. 需重视测试建设,由于 Flow 可预见的会越来越多,可能涉及到流程互相交织的情况,仅凭人脑经验很难完整覆盖,交付质量的把关就变得困难起来;对于一些比较追求精确性的模块,可以适当写写单元测试;
  3. 保持强的同理心,在核心逻辑得以保障,有机会做些优先级相对低的优化的情况,多思考用户是怎么用自己正在开发的软件,发现一些可能影响体验的问题时,对应做处理。同时也通过一些比较细致的情感化的扩展,给软件注入多一些灵魂,给用户带来多一点品牌理念的传达与归属感觉。

小结

写到这里,对于 Web 向 GUI 开发的工作,大概就形成了一个比较完善的思维模型。当然这里也伴随着一点视野的局限,更多是在 Web 前端的视角所展开的,Mobile Native App 和 Desktop Native App 我暂时没有太多研究,也许未来有机会研究到那里的时候,会有些新的感悟,到时候再继续写写和聊聊吧。

相关讨论:浅谈 GUI 应用开发 - 0xFFFF