聊一聊 UI 代码要怎么写 | 分治篇

  • 本文的主要价值: 提供一份软件开发中分治 UI 逻辑的实践样本
  • 关键词: 分治、 UOP

欢迎关注微信公众号「Swift 花园」

引子

我们知道,分治策略是人们解决问题的一种基本策略。问题规模越大,内部包含的差异化的细节越多,越需要执行分治策略。

所谓化整为零,化繁为简,逐个击破讲的都是分治。在计算机领域,我们要列举分治的例子,大的可以聊到七层网络模型(本质上分层也是一种分治),小的可以讲起二分算法。

本文基于 Android 客户端开发中经常涉及的交互逻辑编程展开,表达我对于 UI 分治在软件开发中如何实践的理解。

从 MVC 说起

经典的 MVC 设计模式想必各位程序猿们无人不晓。 MVC 最早存在于桌面程序中,后来由于其强大的复用性被广泛地发掘和应用于各端的开发中,还衍生出 MVP , MVVM 等变体。如果把 MVC 的变体都算作 MVC ,可以说现如今任何一个成熟的 GUI 框架都内化了这种设计模式。放图:

然而我今天不是要来谈 MVC 设计模式的,因为网络上以各种姿势深入浅出 MVC 的好文章已经多的不能再多。之所以提及 MVC 模式只是想借此提醒读者, 解耦 对于编程的重要性。

MVC 之所以看起来很简单却又如此广泛地被使用就在于它有效地解决了一个已知的,规模庞大的 耦合 问题:“你看到的”(不妨理解为 MVC 中的 View ) 和 “它代表的” (不妨理解为 MVC 中的 Model ),这两个东西永远不可能完全一致。

我们有时候需要用相同的表象去表示不同的真相,有时候则需要用不同的表象去表达同一个真相。而当我们尝试把两者捆绑在一起处理时,一旦映射关系发生变化,这个捆绑体便不再适用,需要重新构建。这样一来,原来的东西就都不能用了。所以我们需要一套机制来避免这种耦合,从而实现 Model 和 View 各自的复用。

想要简单的理解这个问题,思考一下我们如何造字,为什么只需要造出常用的几千个汉字就能够表达一个人在生活中遇到的绝大部分事物?又为什么同一个字词在不同的场景下可以表达不同的含义?

MVC 正是通过解耦 Model 和 View ,使得大量的 UI 可以被标准化,进而被重复利用。而这个问题之所以规模巨大,是因为只要一个计算机程序是给人类用的,就一定会涉及到人机交互,也就是我们常说的 UI 。

在解决了耦合问题并实现复用时, MVC 引入了 Controller ,我们的 UI 怎么写将围绕 Controller 展开。


UI 写在何处

在现今我们用到的主流应用框架中,你很难找到针对 GUI 编程部分只提供手写原生代码来实现界面的个例 — 它们无一例外地会引入基于某一种或者某几种标记语言的界面编程机制。其中最常见的是 xml 及其变体。

使用标记语言设计 UI 的最大好处在于它们可以被方便地转换为可视化的编辑界面,这样的话可以允许程序员以所见即所得的方式直观地进行界面设计,即 可视化编程 。提到可视化编程,桌面端的开发框架中大家最耳熟能详的想必包括微软的 .NET 。之所以特别提及 .NET ,是因为我认为微软在可视化编程技术和可视化集成开发环境方面的贡献至今仍是值得称道的。作为编程的初学者,我当年曾一度被 VisualStudio 的强大惊艳到。

在 GUI 编程方面摸爬滚打过几年,我先后使用过 MFC , Windows Forms , Qt 等框架开发桌面端的用户界面,目前从事的是 Android 客户端开发。从这些使用过的开发框架的用户界面部分的组件,我发现一个共同点: UI 都是一定程度上独立的。

首先,在设计阶段,通过使用单独的资源文件夹或者 .xml , .qml , .ui ,又或者是 C# 分部类,界面部分的生成逻辑是被隔离开的。然后,在使用阶段,界面部分的元素通过约定的方式查找或者引用,并且建立响应逻辑。这样的运行方式,使得 UI 和其他程序逻辑被天然地划分开,能够让程序员把 UI 的代码编写从整体的编程活动中独立出来,从而便于维护和协作。

UI 编程的两个阶段

不妨把 UI 设计和 UI 使用的这两个阶段称为 UI 编程的两个阶段 。我在后文会介绍到 UOP 和从 UI 快速切入别人的代码结构进行修改的策略,都是基于 UI 实现所具备的这种独立性。从这里开始,相关话题我会以 Android 平台的客户端界面设计为例展开。

Android 的 UI 可以通过 xml 设计,运行时由系统加载和创建出界面,也可以通过代码直接创建。相对来说,前者的使用方式比较普遍,以下描述会基于采用前者方式的前提。在 UI 编程的第二个阶段, Android 通过 findViewById 的方式将 UI 元素从被隔离的区域 (xml) 中找出来,准确的说,是通过预先定义好的 id 将第二阶段所关心的 UI 元素从被隔离的区域 (xml) 中找出来,为它在控制器逻辑中建立一个引用,然后围绕这些 UI 元素编写交互逻辑。

到目前为止,我所说的是大家都已经知道的事实,而这一节要聊的关于 UI 写在何处 —— 跟我们已知的这些事实有什么关联呢?一些有过 Android 开发经验的同学想一想 RoboGuiceButterKnife 以及官方的 Android Data Binding 为何会存在?也许会对这个问题有自己的答案。

负担过重的 Controller

在我看来,在编程框架内置的 MVC 模型中,单一的 Controller 一直负荷了过重的工作,因为通常的情况是许多的 Model 和 View 都仅仅通过某个唯一的 Controller 建立关联,与此同时我们忽略了一个重要的事实:单一的 Controller 通常不仅要服务于 UI ,往往还需要承载多个不同角度不同层级的业务逻辑。

面对这个问题,我们能做些什么呢?有的读者可能已经想到了 MVVM 模式。首先,要肯定一下 MVVM 是一个可以考虑的选项。在我看来, ViewModel 本质上就是将 Controller 中与特定 UI 密切相关的逻辑集中在一起。不过呢,我会倾向于认为 MVVM 是 MVC 的一个变体, ViewModel 是我们在实现 Controller 时所采取的一种策略:这种策略叫做分治。那么为什么 MVVM 只是可以考虑的选项而不是根本的解决方案呢?我的解释是:因为 分治 这件事,太依赖于具体情况了。没有一种框架可以告诉我们应当如何拆分一整个复杂的控制器逻辑,如你所见,它们顶多帮你把 UI 隔离到另外的空间,让你的代码不至于一上来就看着一团乱麻。而我们在编程的时候,还可能会不断地把 UI 找出来,重新加入我们的控制器,并为它们书写冗长的交互代码。有的时候这些交互的复杂程度是已经具备一定约束规则的 ViewModel 所无法预见和适应的。

其实,无论 MVC 还是 MVVM,其实都是可行的方案,但是我们不能太拘泥于形式,不能规定 View 和 Model 必须以这样或者那样的方式建立关联。在具体的场景中,需要视逻辑的复杂度对控制器中的 UI 部分进行拆分。控制器中可以拆分的逻辑当然并不局限于 UI 部分,不过它与本文无关这里不特别说明。所有分治的 UI 控制器中的 UI 虽然是由原来的那个控制器统一创建的,但是在使用时却可以由各分治控制器自行把握。

这里提供一个参考思路:在实际的开发实践中,我做过的控制器拆分通常是以可复用的组件作为目标,根据模块分工,代码规模等情况综合考虑的。


UOP—— 分治 UI 控制逻辑的利器

接着第 2 节文末提出的思路,笔者在本文的最后一节要来重点分享一下在实践 UI 控制逻辑分治这件事时的一种十分有效的编程方式: UOP

面向 UI 编程。你无法在 wikipedia 上找到这个词条。因为它是杜撰的。实际上,我将要说的 “面向 UI 编程” 应该是面向切面编程(Aspect-oriented programming)的一种,只不过切面聚焦在 User Interface 上。

在展开 UOP 之前,让我们先回到 Controller 这个概念,在我们熟知的框架内化的 MVC 模式中,原先的那个 Controller 角色必定是已经提供 UI 的访问途径了,比如 Android 框架的控制器 Activity ,它提供了 findViewById 。而分治出去的 Controller (同时也是我们自己创建的 Controller )要如何取得 UI 呢?一些同学可能会想到,使用 setter。是的,我们完全可以这么实现。不过这里要提供另外一种思路 —— “UI 包装器” ,我给它取了一个代码名,叫 UIWrapper 。我们将通过这个 “UI 包装器” 来说明 UOP 是什么,以及我们为什么使用 UOP 。

一个 Android 上的 UI 包装器可以是如下的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
    public class UIWrapper {
/** 布局资源 id 到根视图的索引 */
protected SparseArray<View> mLayoutIdToRootViewIndex = new SparseArray<>();
/** 视图 ID 到子视图的索引 */
protected SparseArray<View> mIdToSubViewIndex = new SparseArray<>();

public UIWrapper() {}

/**
* 设置 UI 元素
*
* @param otherWrapper 给定的 UI 元素包装器
*/
public UIWrapper setUi(UIWrapper otherWrapper) {
mLayoutIdToRootViewIndex = otherWrapper.mLayoutIdToRootViewIndex.clone ();
mIdToSubViewIndex = otherWrapper.mIdToSubViewIndex.clone ();

return this;
}

/**
* 包装 UI 元素
*
* @param viewId 指定的视图资源 id
* @param view 指定的视图资源 ID 对应的视图
*/
public UIWrapper wrapUi(int viewId, View view) {
return wrapUi (viewId, view, false);
}

/**
* 包装 UI 元素
*
* @param viewId 指定的视图资源 id
* @param view 指定的视图资源 ID 对应的视图
* @param treatAsViewGroup 以 ViewGroup 方式处理包装的 View
*/
public UIWrapper wrapUi(int viewId, View view, boolean treatAsViewGroup) {
if (viewId >= 0 && view != null) {
mIdToSubViewIndex.put (viewId, view);
if (treatAsViewGroup) {
mLayoutIdToRootViewIndex.put (viewId, view);
}
}

return this;
}

/**
* 根据 view id 查找 UI 元素
*
* @param viewId 目标视图的 id
* @return view
*/
public View findViewById(int viewId) {
View view = mIdToSubViewIndex.get (viewId);

if (view == null) {
for (int index = 0; index < mLayoutIdToRootViewIndex.size (); index++) {
View layout = mLayoutIdToRootViewIndex.valueAt (index);
if (layout != null) {
view = layout.findViewById (viewId);
}

if (view != null) {
mIdToSubViewIndex.put (viewId, view);
break;
}
}
}

return view;
}
}
```

UIWrapper 这个类很简单,它的功能概括成一句话就是动态扩容地持有一组 View 的匿名引用,并且具备通过 viewId 来检索 View 的能力。(以动态扩容地持有匿名引用的方式来实现代码复用,是一个简单却极其有效的实用技巧,在我的另外一篇文章 __用 “管道” 设计拆分复杂处理流程__ 中也有提到,感兴趣的读者可以移步一阅。)

UIWrapper 在具体场景中是以继承或者依赖的方式被使用的,如果我们的分治 UI 控制器本身没有基类,可以直接继承自 UIWrapper ;如果已经有了继承结构,可以引入 UIWrapper 作为成员,进而间接引入目标 UI ,比如 Fragment 就适用这种情况。题外话,说到 Fragment,读者会不会意识到它其实就是框架本身提供的一种分治策略的具体实现呢?

对于以继承方式使用 UI 包装器的方式,我们不妨设计下面这样一个基类 AbsUIController ,也就是前文我们说的要分治的 UI 控制器。

```java
/**
* 分治的 UI 控制器基类
*/
public abstract class AbsUIController extends UIWrapper {
// 生产环境中这个抽象层级中除了构造方法外还有一些实际的基础功能,
// 但是与本文重点无关,此处略过

public AbsUIController(@NonNull Activity activity) {
super();
wrapUi (0, activity.getWindow ().getDecorView (), true);
}

public AbsUIController(@NonNull Activity activity, UIWrapper ui) {
super();
setUi (ui);
}
}

再基于基类的 UI 控制器,我举一个在生产环境中实现的 UI 控制器为例,它的功能是为所有由它管理的 UI 元素提供独占可见状态的显示和隐藏过程处理的可复用的工具类。通俗的说,就是处理几个 View 同一时间只显示其中一个的情况。以下是简化的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MonoDisplayController extends AbsUIController {
...

/**
* 独占展示空间显示给定视图
* @param view 要显示的视图
*/
public void monoDisplay(final View view) { ... }

/**
* 隐藏由当前包装器管理的全部视图
* @param delayBeforeStart 开始隐藏前的延时
* @return 整个隐藏过程完成的总耗时,以毫秒计
*/
public long hideAll(long delayBeforeStart) { ... }
}

读者是否可以想到这样的一个简单的 UI 控制器可以用在哪里呢?不妨先看一下 MonoDisplayController 这个类的用例的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 将可见性互斥的 UI 元素包在同一个 MonoDisplayController 里,包含可下载图标,下载进度等 
// 注意,生产环境中的 UI 元素数量可能很多,都要求互斥显示,这时复用代码更能体现优势
viewHolder.monoDisplaysOnDownloadStatus = new MonoDisplayController (getActivity ());
viewHolder.monoDisplaysOnDownloadStatus
.wrapUi (R.id.iv_download_available, viewHolder.ivDownloadAvailable)
.wrapUi (R.id.download_progress_view, viewHolder.downloadProgressView)
.wrapUi (...)
//....
// 根据下载状态互斥展示不同的 UI 元素
switch (entity.getDownloadStatus ()) {
// 正在下载
case DOWNLOAD_STATUS_DOWNLOADING:
viewHolder.monoDisplaysOnDownloadStatus.monoDisplay (viewHolder.progressView);
...
break;
// 下载暂停,删除,未下载
case DOWNLOAD_STATUS_PAUSE:
case DOWNLOAD_STATUS_DELETED:
case DOWNLOAD_STATUS_UN_DOWNLOAD:
viewHolder.monoDisplaysOnDownloadStatus.monoDisplay (viewHolder.downloadAvailableView);
...
break;
}

限于篇幅没有奉上无关的代码。我相信如果读者有过用许多 if , else 分支来协调多个 View 的可见性这种编码经验的话,应该会认同这样一个控制器是能够有效减少代码量和避免错误的。

讲到这里, UOP 的实例已经在代码中完整给出。读者请重点留意一下 UIWrapper#wrapUi (int viewId, View view) 这个方法,它体现了 UOP 最核心的思维方式: UI 是第一度的逻辑出发点。首先搞清楚我们要跟哪些 UI 打交道,然后才是要在这些 UI 上做些什么事情。

1
2
.wrapUi (R.id.iv_download_available, viewHolder.ivDownloadAvailable)
.wrapUi (R.id.download_progress_view, viewHolder.downloadProgressView)

解释完 UOP 是什么,最后再来解释一下为什么 UOP 是分治 UI 的利器。前面说过 UOP 其实是 AOP 中的一种,而我认为 AOP 其实是贯彻第一性原理的一种思维方式:聚焦任务在同一维度的事物构成的某一条线索上。UI 是程序代码中被最直观呈现的东西,以软件用户的角色观察,UI 可能就是他可以感知的全部,这是一条最自然的线索。而对于程序员来说,这个认知其实也是完全适用的。

如果上面的描述不好理解,我再举一个维护代码的例子,程序员朋友可能就会有共鸣了。

假定有程序员甲开发了一款软件,他很熟悉这款软件的代码。还有程序员乙从未接触过这款软件的代码。有一,乙在甲不能提供帮助的情况下,(比方说甲请假了)接受了一个任务:他要在短时间内调整一处 UI 。对于乙来说,最快的做法是先去阅读代码吗?恐怕不是。然而,现实场景中,在对代码一无所知的情况下,要我们基于别人的代码增改功能,这并不少见。倘若时间有限,无法阅读太多的代码,那么抓住目标的特点,从代码中找到线索,利用线索切入,则是快速搞定任务的靠谱思路。这其实只是专注于任务本身的结果,而并非什么独特的解题技巧。对于 UI 来说,不论是框架内化的设计模式,还是它本身最直观的外在呈现,往往都天然地提供了一条独立于常规代码之外的 线索。

使用 UOP ,本质上是在分治 UI 控制逻辑的同时,仍然保持对关注点的聚焦。虽然 UI 被分出来了,但事还是那些事,并没有因为设立了一个分管的去处,就多出了额外的东西。同时,又因为分管,被分出来的这些事,作为一个可单独运行的逻辑,自身变得更加纯粹了,更容易被复用。当然,它带来的最重要的好处是:维护这件事变得更容易了。


Linkedin
Plus
Share
Class
Send
Send
Pin