用“管道”设计拆分复杂处理流程
- 本文的主要价值:提供一种抽象复杂逻辑,达成功能复用的思路
- 关键词:语义提炼、动态具名
- 本文约4000字,建议阅读时间30分钟。
引子
在软件开发时常常遇到一种场景:随着产品功能的扩展,出现了多个具备高度相似性的功能单元。这些功能单元可能有着相似的交互逻辑,提供同类的输入数据和输出数据。并且对于用户来说,它们共同处理同一个元素。举个例子,比如一款修图app,它包含了一组编辑功能,每个功能都作用于一张图片,处理之后的图片还可以作为其他功能的输入。作为编辑工具,在每个功能内部,还可能都需要支持撤销和重做这样的用户操作。容易想到,这些功能间存在着许多可以进行复用设计的代码。
本文基于文猫君对所从事的一款图像处理应用开发过程中的一次代码重构的实践所做的回顾发起,期望达到记录和分享的目的。
重构的具体背景
请先看下面这幅图:
重构前的图像功能模块结构.png
图中的元素很少,但细心的读者可能留意到了:图中的“内存图像管理+效果处理”是一个“黑盒子”。那么这个黑盒子是一件好事还是坏事呢? 既然我们对这种设计做了重构,那么这里重点来探讨一下它的缺点。 黑盒子的优点部分,作为思考题留给读者。
在具体业务场景下,我发现旧有设计的两个问题:
- 图像处理接口粒度太大,难以复用代码;
- 黑盒子把图像管理和图像效果处理这两件事包在了一起,使得外部难以灵活的接触和使用图像。
重构的设计思路 —— “管道”概念的提炼
问题一在重构时也得到了解决,但与本文要表达的设计思想关联不大,就此略过。
为了解决问题二,我引入了两个概念:“流水线”和“例程”。相信对于从事计算机领域工作的读者来说,这两个词不会陌生。
流水线 pipeline, [计] 又称管道,管线。
例程 routine, [计] 程序;日常工作;例行公事
在具体代码实现中,<code>Pipeline</code>相当于内存中的图像状态机,提供了基本的管理图像的能力,例如加入图像,删除图像,复制图像,移动图像等。<code>Routine</code>相当于各个图像功能单元中通用的事务,比如每个图像功能单元都需要在其开始运作时从某处获得一份初始图像,并在其结束运作时输出一份最终的图像到另一处。我们约定,<code>Routine</code>中的事务会基于<code>Pipeline</code>来完成。它的含义是:每个<code>Routine</code>都会包含一组基于一个或者多个<code>Pipeline</code>的典型操作,然后再加上每个<code>Pipeline</code>的差异化操作(即后文会提到的差异化的图像处理步骤),共同构成一个完整的具体场景下的图像功能单元。
下文中我们不妨把“流水线”的释义直接替换成“管道”,因为后面用到的一些比喻性的描述文猫君觉得用“管道”一词衍生出来会比用“流水线”更自然一些。接着我们对“管道”这个意象再做进一步的挖掘,可以有下面一些对应关系(表格中左侧的概念只是我的比喻,读者可自行体会,这里不会全部详细解读)
“管道” <code>Pipeline</code> | => | 图像状态机 |
---|---|---|
“流体” | => | 图像 <code>Image</code> |
“节点” | => | 图像状态 <code>ImageState</code> |
“流动” | => | 图像状态流转 |
“锋面” (流体的最前端<code>Waterfront</code>) | => | 当前正在处理的图像状态 <code>currentState</code> |
“连通性” | => | 状态机内的图像,图像状态机之间是可串联的 |
“流体”是一个名词,它对应的是图像,涉及到存储模型。根据“流体”的特性我们推断管道里的图像存储模型应该会被设计为平行结构。
再来看“流动”这个动词,它对应的是图像状态流转,涉及到业务逻辑,我们不妨把它称为“工序”。
请读者联想一下< 化妆/整容 VS 软件上美化图片上的人脸 >这种类比关系,再想一下两者在存储模型和工序这两个方面有什么异同? 留作思考题。
回到正题,我配了五幅图来描述管道在具体实现中的五个特性:
-
流体由一系列节点(即图像状态<code>ImageState</code>)组成。图像状态的含义构成了我们对某一个图像的本征性认知。通俗的说,图像状态能够帮助我们在特定场景下把不同的图像区分开来。举个例子,协同开发的两位开发者对于“美颜”和“滤镜”这两个步骤的认知达成了共识。于是我们就可以建立两个节点:“美颜”、“滤镜”,然后在开发过程中使用这两个节点来协作。注意到图像状态不是图像本身,图像状态的代码实现上我们可以使用一个极轻量的数据结构——字符串。它体现的是占位符思想,而占位符的重要好处是它是可预见的(基于认知共识)、可预置的(它很轻量)、可固化的(可复用性代码的一个诉求)。
-
管道通过衔接节点构成连通。在节点中有必要特别提出“同位节点”的描述。它指的是几个步骤在同一个图像上先后发生。在时间上有先后但在空间上始终操作同一份存储。后面会再用到这个描述。
流体与连通性.png -
流动的流体会有一个最前部,好像水流的最前端,又称“锋面”<code>Waterfront</code>,对应着管道中的所有图像在同一时间里只会有一个图像处于可操作的状态,这个状态代表着图像的变化趋势。具体到代码中的实现可能会是一组带有同步关键字的方法加上一个唯一的当前状态的指针。开发者通过引导和操刀这个趋势,把图像引向最终要呈现出来的样子。在图示中,我们有意使用了绿色代表原始的、最初的,使用红色代表成熟的、完全体的。<code>Pipeline</code>专注于做一件事,就是把图像从一种状态转化为另外一种状态。这期间可能要经历很多个节点,而<code>Waterfront</code>的意义就在于它保证了<code>Pipeline</code>的操刀者可以明确地知道这一刻只有他自己在引导图像的流向而没有别人会干扰到这件事。
-
流动可以是双向的(相比生产车间的“流水线”,释义替换为“管道”更自然的原因是后者可以实现双向流动,对应着图像可以实现反向编辑或者说撤销到一个处理步骤之前的状态)
-
流体如果分流则可以出现多个“锋面”,对应着图像的并行处理。
锋面和流动.png
管道的具体实现
如前文所述,“流体”即图像,做简单的封装即可。我们主要需要实现的是“节点”和“锋面”,“流动”和“连通”。
节点的实现方案和意义
我们先来看一种典型的图像处理过程中可能会采用的代码写法:
// 图像xyz的描述
Image xyz;
// 图像ijk的描述
Image ijk;
// 图像abc的描述
Image abc;
其他图像及其描述...
// 图像的getters:
getXYZ();
getIJK();
getABC();
其他图像的getter...
不难发现,如上的代码无法复用,因为每一个图像的引用被赋予了非常具体的含义,同样的写法不会完全适用于另外一个图像处理场景,因为另外那个图像处理场景可能不会用到描述为ijk的图像,可能会用到描述是uvw的图像。因此采取这种写法会遇到的一个典型问题是:每新增一个图像处理场景,我们是不是需要新增若干个特定描述的图像声明?在代码层面,这无疑会造成冗余。
这里的图像引用,其实就是我们所说的图像管道里的某个“节点”。要对“节点”实现代码复用要怎么做呢?通过分析上面的写法中代码不能复用的根源是图像引用的用途被具体定义(同时也是被具体约束),我想到,那么为什么不能把图像引用匿名化,让它的含义在具体场景到来时才被赋予呢?
说到这里,有的读者会想到一种数据结构——<code>Map</code>。是的,没有什么奇淫巧技,只是用了映射,就能解决这个代码复用问题中的最大障碍——既然无法预知我们可能需要处理什么样的图像,可能需要处理多少份图像,并且这些未知数总是易变的,那么为什么不让具体场景的使用者来动态添加这些图像引用,并且为它们具名呢?图像部分被复用的代码,这里只声明了一样东西,就是从图像状态表述到图像引用的映射表。它解决了一个之前的写法不具备的达成复用的前提:图像存取的方式是统一的,有限的,因此是可固化的。
Map<String, Image> stateTagToImageMap = new HashMap<>;
我们用一个字符串标签来表示图像的状态。对于图像管道的使用者来说,他只需要理解每个标签的含义,通过标签来存取图像并进行处理。在这些标签中,我们再提炼出几个具有通用含义的代表,比如<code>Original</code>代表“最初的”,<code>Processed</code>代表“加工完成的”,这正是前文提到的占位符。容易理解,你可以声明并且预置许多占位符在一份可复用的代码库中,可你不会声明同样数量的图像引用到这个代码库——这样很奇怪。哪怕从程序实现的角度来说,没有分配实际空间的引用并不一定会占据更多内存。在后文中列举代码范例时我们将会经常地用到<code>Original</code>和<code>Processed</code>这样的标签。
不妨阅读以下这段代码,这是一种使用标签来操作其对应图像的写法。
// 显示两个处理步骤之后的图像
pipeline.from(tag_Original) // 从原始的图像开始
.copy_to(tag_Processed) // 拷贝出一份图像,用于处理,命名标签processed
.doProcess(tag_Processed, specificProcess_1) // 执行特定操作1
.doProcess(tag_Processed, specificProcess_2); // 执行特定操作2
showImage(pipeline.fetch(tag_Processed)); // 取得processed标签代表的图像并且展示
锋面的实现方案和意义
解决了节点的设计,我们再来看基于节点之上提炼出的“锋面”要怎么设计。容易理解,锋面是最前面的那个节点,具有唯一性,对应具体的图像处理代码中就是“当前正在被处理的那个图像”。当我们在设计图像管道对外提供的处理接口时约束处理动作一定只能发生在这个“当前的”图像上时,能够保证我们的“图像流”总是按照我们想要的方向流动,并且在这个过程中,“图像流”是不会被篡改的。这是我们的图像编辑功能要实现撤销和重演功能的基本前提。
还是上面那段显示两个处理步骤之后的图像的代码,去掉处理接口的标签参数,因为我们约束了处理总是只能发生在唯一的、当前的图像上。
/* 显示一个处理步骤之后的图像 */
pipeline.from(tag_Original)
.copy_to(tag_Processed)
.doProcess(specificProcess)
showImage(pipeline.fetch(tag_Processed));
如果要求能够回撤到第一个处理步骤之后的状态,再做第二个处理步骤,并且第二个处理步骤的参数是可以改变的。可以这么做:
/* 显示一个处理步骤之后的图像, 但我们在过程中保留了第一个步骤的状态 */
pipeline.from(tag_Original)
.copy_to(tag_specificProcesse_1) // 相比一步到位,这里多存储了第一个步骤的状态
.doProcess(specificProcess_1)
.copy_to(tag_Processed)
.doProcess(specificProcess_2.setParams(params_t1))
showImage(pipeline.fetch(tag_Processed));
/* 调整第二个步骤的某些参数,重新显示图像 */
pipeline.from(tag_specificProcess_1) // 之前存储了第一个步骤的状态,直接从这个步骤开始
.copy_to(tag_Processed)
.doProcess(specificProcess_2.setParams(params_t2))
showImage(pipeline.fetch(tag_Processed));
流动和连通性的实现方案
有了节点和锋面,流动和连通就有了作用的主体。对应到图像编辑功能,流动其实就是图像从一个状态变成另外一个状态的过程。连通则更好理解,一个管道出来的图像可以被另外一个管道接纳,由此构成管道之间的连接。连接在一起的每一节小管道各司其职,灵活组合,再构成更长跨度的大管道或者“管道网络”,从而协同完成复杂的业务流程。
回归到代码,我们来看一组步骤稍多的图片处理工序如何体现出管道的流动性和连通性。刨去内部实现细节,整合或者忽略一些与管道设计思想关联不大的逻辑,以下代码在流程上已经比较接近实际生产环境了。
/** 主功能区,不妨将它的例程称为Main
* 基本功能:
* 1. 展示图像
* 2. 可以从这里进入各子功能处理图片再回到这里展示新的图片
* 3. 撤销到经过某个步骤处理之前的图像或者重做出之前做过但是被撤销掉的某个步骤的图像
*/
Routine_Main.startFrom(image_file) -> {
Routine_Main.pipeline.loadFrom(image_file, tag_Original) // 从图片中加载初始的图像
}
Routine_Main.showCurrent() -> {
showImage(Routine_Main.pipeline.front(); // 显示“锋面”
}
/** 进入到一个叫“美型”的功能区,对应的例程称为FaceLift
* 基本功能:
* 1. 展示图像
* 2. 针对图像中的人脸轮廓,五官进行形状调整
* 3. 输出处理后的图像到主功能区
*/
Routine_FaceLift.startFrom(Routine_Main.pipeline.front().copy());
Routine_FaceLift.process() {
Routine_FaceLift.pipeline
// 这个过程用户无法干预,不会有“重演”,因此我们可以直接在原稿上操作
.from(tag_Original)
.doProcess(faceLift_step_1_process)
.doProcess(faceLift_step_2_process)
.doProcess(faceLift_step_3_process)
...
}
// 把子功能“美型”处理好的图像提交给主功能
Routine_Main.accept(Routine_FaceLift.commit())
/** 进入到一个叫“滤镜”的功能区,对应的例程称为Filter
* 基本功能:
* 1. 展示图像
* 2. 滤镜化处理图像
* 3. 输出处理后的图像到主功能区
*/
Routine_Filter.startFrom(Routine_Main.pipeline.front().copy());
Routine_Filter.process() {
Routine_Filter.pipeline
// 这个过程中用户决定要选用哪个具体的滤镜,因此每次都需要基于原稿复制一份再滤镜化
.from(tag_Original).copy_to(tag_Processed)
.doProcess(filterProcess(pickFilter("awful")))
... // /皱眉,这个不好,换一个!
.from(tag_Original).copy_to(tag_Processed)
.doProcess(filterProcess(pickFilter("notbad")))
... // /托腮,这个还行,再换个试试~
.from(tag_Original).copy_to(tag_Processed)
.doProcess(filterProcess(pickFilter("perfect")))
... // /完美~
...
}
// 把子功能“滤镜”处理好的图像提交给主功能
Routine_Main.accept(Routine_Filter.commit())
/** 进入到一个叫“美颜”的功能区,对应的例程称为SkinBeauty
* 基本功能:
* 1. 展示图像
* 2. 针对图像中的人脸皮肤进行色相调整
* 3. 输出处理后的图像给Main功能
*/
Routine_SkinBeauty.startFrom(Routine_Main.pipeline.front().copy());
Routine_SkinBeauty.process() {
Routine_SkinBeauty.pipeline
// 这个过程用户可以调节一个滑竿来控制色相参数,每次都基于原稿复制一份再调色相
.from(tag_Original).copy_to(tag_Processed)
.doProcess(skinBeautyProcess(level_too_weak))
... // /托腮,效果好像不明显,加强一点
.from(tag_Original).copy_to(tag_Processed)
.doProcess(skinBeautyProcess(level_too_much)))
... // /皱眉,好像有点过头了,往回调一点
.from(tag_Original).copy_to(tag_Processed)
.doProcess(skinBeautyProcess(level_just_right)
... // /完美~
...
}
// 把子功能“美颜”处理好的图像提交给主功能
Routine_Main.accept(Routine_SkinBeauty.commit())
// 纠结一下。。
// /犹豫,要不还是不美颜了吧?
Routine_Main.undo();
// /迟疑,滤镜也不要了?
Routine_Main.undo();
// /思考中。。。
// ...不行,还是都加回来吧
Routine_Main.redo().redo();
// 端详5分钟。。。完美~~~
save();
总结
关于管道的设计思路和实现方案介绍到此,读者可以回顾一下本文一开始所提到旧有设计的第二个问题:“图像管理和图像效果处理被包在一起”。那么这个问题在管道方案中是不是已经解决了呢?
归纳一下,管道的基础是“图像”被无差别的管理着,被管理的每一个图像由最初将其投入管道的创建者为其定义标签。最初的创建者和后来的协同者只需要对这个标签的含义达成共识,便可以进行协作。管道的思想是模拟流体的运行方式来实现图像处理过程,通过节点的设定来分解处理步骤,通过锋面的操控来聚焦每个单步的操作,通过双向的连通性来将分治的逻辑重新串联起来完成复杂的功能。