一次设计的深度复盘

前言

作为一个iOS 开发说起设计,都会大量提到MVC、MVP、MVVM、MVVM-C、MVI、VIPER等等,面试中也经常会被问起,演进思路抽象的来讲就是随着业务的复杂,某一个模块的代码不断膨胀,为了解决它的膨胀问题,我们将执行某一类功能的代码提取出来放到约定后的另一个类里并约定它们的交互方式,如MVC中的C代码不断膨胀,我们抽象出ViewModel的概念将符合它职责范围内的代码从C中移到VM中以减轻C的负担。

那如果VM的代码也不断膨胀呢?

相信每一个APP都会有几个超级页面(业务的复杂导致代码量巨多),利用现有的模式某个模块的代码量依然超多,那要不要继续演进以解决这种超级页面代码量过多的问题?我们知道业界有很多更复杂的的概念如VIPER,或者我也可以提出一个MVVMVM的概念(😂这不重要,我还在网上找到了MVPVM的概念)。记得笔者最早阅读完《objc.ioAPP架构》这本书后仿佛找到了架构的金钥匙,里面介绍了大量的如MVVM-C、MVC+VS、MAVB、Elm等之前没听过但就是觉得很牛逼的架构概念,幻想着有朝一日把它应用在现有的工程中,完成"架构升级"(成为架构师、迎娶白富美、走向人生巅峰,嘿嘿🙄🙄)。

那通过这种模式演进能解决这类问题么?

答案是肯定不能的,我们并没有解决问题,用繁重的VM代替了繁重的C,再用繁重的VVM替换繁重的VM(可以无限套娃下去),而且每演进一层都会带来很多问题,就拿VIPER举例:

  • 认知成本:要让大家都理解这个设计并遵守它,这本身就是成本,你可以说这是一个优秀工程师应该掌握的,但事实是很多人连MVP与MVVM的区别都搞不清(有很多人都说区别是数据视图的双向绑定,但MVVM中并没有体现出Binder的概念,如果我的MVP中也使用双向绑定的技术那它跟MVVM还有区别么?欢迎大家留言讨论哈😁);
  • 维护成本:引入了更多的类与关系,一个功能的代码被散落的到处都是,使复杂加剧,一个功能的改动整条链路的类都要跟着改,并不符合开闭原则;
  • 持续收益:为了保障项目架构的统一性一般整个APP都会采用同一套架构设计,即使很简单的页面我们也会按照架构模板把它拆的如此的"碎",且拆分后并不会V、I、P、E、R全面发展,有肥有瘦最后还是会有类一家独大,然后继续陷入这个循环。

其实我们所说的MVC、MVVM等并不是设计模式,而是一种软件架构模式。它是描述软件系统里的基本的结构组织或纲要。通过架构模式的升级只是将之前模式中职责进行了更细致的拆分,而这种拆分很难完全拆分出所有职责与其对应的类,总会有些不明确的代码都放到了一起导致这个类又变成一个超级类。

再来看下它拆分的依据是什么:View、Model、Presenter、ViewMdoel...等等的划分更多是从软件架构层面的抽象,虽然确实能使臃肿的模块"瘦下来",但因为抽象的维度不同无法帮助到具体业务的解耦、隔离、扩展等。最后就是我们花了大量时间做架构演进,增加了更多的类与关系,只见成本增加却很难享受到收益(时间证明,这本书上讲的架构模式也并没有广泛的推广起来)。

那到底应该怎么解决?

要想正了八经的解决这种问题就不能按照架构模式生搬照抄了,而是要根据业务的实际的复杂 引出对应的设计

一点理论

在提到设计、架构等话题时会有一个比较好玩的现象,工程师们会分为两派:保守派是毫无设计,激进派是过度设计,两者经常在方案评审时"互喷"。作为小白的我觉得双方说的都有道理左右摇摆并泪眼汪汪的看着组里的技术大牛渴望知道答案时,大牛会冲你微微一笑,并吐出四字真言:

盐放少许。

听完有种想骂人的冲动🙃🙃少许是多少?? 这不等于没说么??

但当你真正成了大厨后你就会觉得这的确是这个问题的标准答案,因为不同的菜所放的盐确实是不一样的。而反过来,到底应不应该增加设计?要引入多少设计?或者到底要通过设计来解决什么问题?这就取决于很多方面了。

关于复杂

首先来看看为什么会产生这种如此复杂的超级页面,背后最根本的原因其实就是业务本身的复杂,《人月神话》里面提到两个概念:本质复杂度和偶然复杂度。本质复杂度就是在解决问题时无论如何都必须要做的事,而偶然复杂度是由于选用的做事方法不当导致要多做的事。换句话说就是业务的复杂就是本质复杂度,因为引入了某个设计产生的复杂就是偶然复杂度。我们要做的就是引入一定的偶然复杂度来降低一部分本质复杂度,而这个转换率就是评判是否是过度设计的关键。

只有本质复杂度足够高的时候设计的引入才可能有足够的ROI。

关于变化

接下来看看如何通过变化区分它们?根据修改频率的不同我们自然能把它们分为易变化的如UI与不易变化的如业务的核心逻辑,我们的设计就是为了将它们隔离开来达到分层的目的:

  • 让不易变化的下沉,对上层不透明,不受易变化部分变化的影响,且要提供抽象的接口让易变化部分更易扩展。
  • 让易变化的上浮,单元更内聚,变化只影响自身、只需关心不易变化部分提供的接口而更少的去关心与其他易变化部分的关系。

那如何做到上述的区分呢?就要提到设计了。

关于设计

提到设计很多人都会先想到23中设计模式,根据解决的问题不同它们分为:

  • 创建型:通过封装复杂的创建过程,解耦对象的创建代码与使用代码,如我们常用的单例模式、工厂模式等。
  • 结构型:主要总结了对象在一起的一些经典结构,这些经典结构可以解决特定应用场景的问题,如:代理模式、适配器模式等。
  • 行为型:主要用于描述类或对象的交互,以及职责的分配,对类之间交互,以及分配责任的方式提供指南,如:观察者模式、策略模式等。

设计模式是人们针对某些特定场景归纳总结出的一些解决方案,很多同学都是从这里开始学习设计的,每种模式的类、对象之间关系是什么样的?解决的是哪些问题?我的哪些场景能用到?然后看到项目中有**"合适"**的应用场景就把它引入到项目中了,这可能也是大多数设计被诟病的原因吧,设计模式就好像武林前辈们总结出来的一些武功招式,没有深厚的内力加持看不透其中的玄机,就只能是花拳绣腿,难成一代宗师。

那如何获取深厚的内力,我认为是设计原则。设计原则就像是武功心法一样,当你熟练掌握设计原则后,再去看设计模式你会发现理解的更加深刻,使用起来更加的得心应手,就好像张无忌学了九阳神功有了雄厚的内力后只用半天就悟透了看似高深的乾坤大挪移。它们是:

  • 单一职责原则(Single Responsibility Principle):一个类或模块只负责完成一个职责。

  • 开闭原则(Open Closed Principle):软件实体应该"对扩展开放、对修改关闭"。

  • 里式替换原则(Liskov Substitution Principle):子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。

  • 接口隔离原则(Interface Segregation Principle):客户端不应该强迫依赖它不需要的接口。其中的"客户端",可以理解为接口的调用者或者使用者。

  • 依赖反转原则(Dependency Inversion Principle):高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象来互相依赖。除此之外,抽象不要依赖具体实现细节,具体实现细节依赖抽象。

  • 迪米特法则(最小知识原则):每个模块只应该了解那些与它关系密切的模块的有限知识。或者说,每个模块只和自己 的朋友"说话",不和陌生人"说话"(不该依赖的不依赖,该依赖的少依赖)。

  • DRY原则(Don't Repeat Yourself):不要重复自己。 重复的代码、重复的轮子、重复的调用。

  • 三次原则(Rule of three):指的是当某个功能第三次出现时,才进行"抽象化"。它的含义是:当第一次用到某个功能时,写一个特定的解决方法;第二次又用到的时候,拷贝上一次的代码;第三次出现的时候,才着手"抽象化",写出通用的解决方法。

  • KISS原则(Keep It Simple and Stupid):尽量保持简单。不要使用一些"奇淫巧技"优化代码而失去了可读性与可维护性。

它们能支撑你更好的使用这些招式,且有了深厚内力的加持即使不用这些招式,平A也能刀刀暴击(渣渣code:系兄弟够来砍我)。

关于分析

那有了深厚的内力又懂得常用的招式就一定能笑傲江湖了么?

答案肯定是否的,除了这些还有很多其他笔者认为更重要的因素:

  • 要能准确分析敌人:它的实力与成长性如何,没必要遇到个哥布林就放元气弹。这个功能的短期与中长期规划是什么?我们要在哪些方面做些面向未来的设计?如何识别业务短期及中长期规划是我认为一个架构师最为重要的能力,结合KISS原则、DRY原则、三次原则能有效的避免过度设计,合理分配资源,有的放矢。很多过度设计都是因为自己YY了一个场景而为了支持这个场景的扩展引入的,实际则是一两年都没遇到过这个问题,而团队却一直在对这个设计所带来的的偶然复杂度买单,待到真正有场景出现时,原本精妙的设计也早已随着多人的迭代而面目全非,彻底沦为时代的眼泪。

善战者无赫赫之功,善医者无煌煌之名,善弈者通盘无妙手。

  • 要能准确判断处境:一共有多少敌人,我有多少查克拉,变成九尾模式无限查克拉的仗谁不会打,而现实却是自己只是个普通忍者,查克拉有限、支援较少,如何在紧急的项目周期与紧缺的人力成本等因素的加持下打倒所有敌人?毕竟速度、质量、成本 三者只能取其二(我们业务方的口号是既要又要还要😂😂),这时候就要结合上一条来做取舍了,哪些人用雷切,哪些人用千年杀(情况实在紧急哪怕是堆砌千年杀也要先把它插死😂😂),千年杀虽然上不来台面会被其他上忍诟病,但却是当时能全量解决敌人的最优解,虽然从结果上看我跟下忍一样都是用简单粗暴甚至有些丑陋的方式解决了问题,但我们出招时背后所蕴藏的思考逻辑却是完全不一样的。做到这些你才能变成特别上忍卡卡西,跟谁都能五五开。

设计是在质量、成本、时间等因素之间做出权衡的艺术。

注:可现实是没什人懂艺术,更不会有人在看这段作品时去闭目感受作者当时的处境,又为何要如此演绎。艺术是"闲人"才去研究的,在绝大多数团队中每个人都很忙,没人会花很多时间去了解所见华丽或丑陋的背后蕴藏着怎样的故事,无赫赫之功 == 普通小卒。这时要有个关于作品的讲记就好了,能帮助快速了解作者创作这幅作品时的处境、框架、细节及背后的原因。

关于文档

关于文档很多技术要求较高的团队都会把它作为团队行动纲领,出现在各种流程制定、团队规划的会议上,但能真正高效持久执行好的却寥寥无几(就像codereview),要么因为项目周期节奏无法负担编写文档的成本,要么过于形式化流于表面,没什么价值久而久之就没人继续了。

笔者也是近两年才开始写设计文档的,之前不写并不是因为懒,而是真心觉得设计文档没什么用(相信不只有我一个人这么认为),浪费时间又不如代码直观,甚至设计文档会产生一些坏作用,就跟注释一样,它们需要跟代码同时迭代,而现实情况是经常出现改了代码而没有去修改对应的注释给未来留下了严重的隐患,而文档离代码更远,更无法直接信任,最后还是要去看代码,就更只是KPI的产物了。

错误的指引不如没有。

那笔者为什么后来又开始"选择性"的写设计文档了呢?我所说的写的"设计文档"跟之前说不写的"设计文档"可以说并不是一个东西,之前的文档内容更多的是HOW,翻译PRD,罗列需求细节流程、展示我的设计等,而现在的内容更多的是WHY,为什么这么设计,之前也说过"设计的本质是取舍",更多偏向的是要讲清楚我为什么要这样设计,这么设计是为了解决那些问题?这样能让参加评审的人更能感受作者当时创作的处境,到底是面对怎样的处境下才能写出"谁知盘中餐,粒粒皆辛苦"这样悲悯的警世名言(李绅:为官极其残暴🤪),也更能帮助你的leader、组内其他优秀的同学帮你分析合理性,代价左移,避免过度设计(也更能接受暂时的一些污点)。

"为什么" 比 "怎么做" 更重要

关于重构

如果一个团队一直紧到要靠堆砌千年杀来插死敌人,那这个团队肯定是有问题的,请珍惜自己的羽毛(和手指),赶紧离开吧。一但项目节奏没那么快了就是我们去还债的时候,重构就是我们还债的利器。

这里笔者不去展开该如何重构和重构的技巧,我只想强调一个观念:功夫在日常,请持续重构。重构应该是融入在每个迭代需求中的,这个需求要改这里而这里有这个问题我来改改,这个需求明确了产品要这么演进那架构应该这样演进。以小颗粒放到需求或者技改池中去完成,而不是无脑地往上堆砌直至无法维护必须停下业务来重构,你是否也有同事经常在边上叫:"MD,天天维护屎山代码!这里有问题,那里应该这样这样,巴拉巴拉",当你问他这个模块交给你维护1年了你都做了什么的时候他就说不说话了(或者参考网上的段子:嘿嘿,我在上面又拉了几坨大的🤪)。比之更恶劣的是那种只关注于远方的哭声却对眼前代码的丑陋视而不见的人,为了所谓的技术演进、先进性、个人价值(or个人利益)去造更多的轮子,追求落地更复杂的架构。

套用诞总的话:历史已经证明,越是那些为了实现远大的技术目标而对眼前混乱不管不顾的人,频频让我们的工程陷入大火。

关于勇者

两方面,一方面是敢于试错敢于重构,重构本身就是一件吃力不讨好的事。

重构的定义:对软件内部结构的一种调整,目的是在不改变软件可观察前提下,提高其可理解性,降低其修改成本。《重构:改善既有代码的设计》

重构本身就是耗时耗心、费体费脑,高风险稍不留意就会出现线上问题,对重构者来说本就是究极折磨,且由于它"不改变软件可观察前提下"的特性对业务方是无感知的,很难解释清楚耗费了这么多时间到底提升了啥?解决了啥?对业务有啥好处?很多人都会从提效的角度去描述,但是你的重构效果最后是否真的能达到你预期的效果取决于我上面说的很多方面,对重构者本人的技术能力、业务能力要求极高,结果及不可控。且就算你符合上述的所有要求,重构的东西也会不断地受到质疑与挑战(喷),你离散了:为什么是一堆?你集约了:为什么是一坨?(WTF🙃)

在写这段文字的时候笔者就陷入了"不太美好"的回忆(相信每个重构者读上段文字的时候都有些画面涌现出来),很多时候我们不缺乏解决问题的智慧,而是缺乏解决问题的勇气,这个时候需要自己给你鼓劲,毫无疑问,重构对重构者能力的提升也是巨大的,别犹豫,放手去干吧。

另一方面是敢于认错,对于已经明确了的问题,错就是错了,能承认自己过错的才是至强者。而对暂未明确的质疑跟挑战,如果你坚信它,别气馁,时间会给我们答案。

关于恶龙

勇者终成恶龙的案例比比皆是,归根结底有以下几个原因:

  1. 方向预测错误:没人能准确规划出未来的走向。

  2. 缺乏持续演进:没有任何一套设计适合业务的所有时期。

  3. 设计本就不利于修改:基本所有的设计模式、设计原则都是要利于扩展的,引入了过度复杂的设计导致它易于扩展但是修改成本极大,导致最后变成产品迭代的瓶颈,经常会听到的是这与我们之前的框架偏差太大了,改动起来成本特别大。

  4. 没有专人维护:我一直认为复杂的框架应该由专人维护,不是每个人都能或者需要理解设计的意图,无法从全局角度自顶向下的思考,往往觉得这里插一行那里改两下就能完成我的需求,每个人都从自己需求的角度插几行代码久而久之整个框架也就面目全非了。

综上所述

可见要做出好的设计除了自身要有深厚的内力外,还要能明确现有的痛点与瓶颈、考虑是否满足业务中长期的扩展、项目与人力成本等,一个好的工程师除了要自身技术过硬外,还要是好的产品和PMO,我想这也是码农与工程师的区别。

一个案例

笔者在三年前重构过业务的订单详情页面,正巧当时系统的学习了一遍设计模式,于是大刀阔斧的一顿操作,三年过去了,业务还在持续高速迭代发展,自己对设计的理解也更加深刻、有了更深厚的内功,想回头来总结下这次页面重构与设计,看看哪些是合理的,哪些是过度设计(其实这才是文章最初的开头,但是写着写着感觉有点干,想还是补一些理论进去吧,于是有了上面的那一堆)。

复盘总结

订单详情页是业务中最复杂的页面,笔者主要负责整个页面容器与其中一条业务线的内容,其复杂主要有以下几个原因:

  1. 要支持多业务线订单的混合展示,涉及到要与不同的业务线代码交互。
  2. 每个业务都由多种订单状态组成,不同的状态下有不同的UI和一大堆复杂的业务逻辑。
  3. 页面为多个模块提供入口,经常面对多人合作的场景。
  4. 这个页面包含订单的生成,生成订单前它的数据是从前面的list页面传过来的,生成单后的数据是从订单详情接口中获取的,两种数据结构并不统一。
  5. 不同状态的UI看似不同,实则有很多相似,笔者希望能从过去的变化中找到其中的规律并抽象出通用代码以适应未来的变化,从而达到的代码复用、快速扩展的目的。

针对上述的几个主要问题,当时给出了这几个答案:

  1. 之前详情页只支持单业务,现在要支持多业务要将原来的业务也变成一个接入方,依据控制反转的思想(框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行)下沉公共逻辑设计框架,子业务通过协议的形式注册到框架中解耦,满足依赖倒置,方便后续更多的子业务接入进来。整体满足在变化章节我们让不易变化的下沉,对上层不透明,不受易变化部分变化的影响,且要提供抽象的接口让易变化部分更易扩展的要求。这部分我认为做的比较好。
  2. 多种状态之间毫无关联应该解耦,当前状态的修改不应该影响其他状态且后续状态会增加应提供便捷的扩展。这种场景属于典型的状态并不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑比较复杂的场景,笔者选用了基于状态模式实现状态机的设计,利用注册配置反射工厂的模式将不同的业务的状态与状态对应的执行类注册到状态机中,然后通过数据驱动,根据当前展示的业务状态,初始化对应的状态执行类,设计抽象的生命周期方法接口,让框架在不依赖业务具体实现的情况下完成状态的流转。各状态间无感知,只需要根据生命周期完成状态本身开始更新结束需要做的内容即可。后续也出现了业务与状态增加的场景,满足了我们对此场景能快速扩展且相互不影响的需求。这部分我认为做的也还可以。
  3. 由负责人(也就是小弟我)为业务方提供入口,业务方只需要在里面填充自己的实现就可以了。参考恶龙章节第4条,我始终认为框架应该由专人维护,不是每个人都能或者需要理解设计的意图,无法从全局角度自顶向下的思考,在我维护的那个阶段能很好地运转,但当我因为工作内容调整后把页面交接出去后,随着同学们的东插西凑,后面马上就劣化了。这是一方面,第二方面是我会在第5条讲述。
  4. 抹平生单前与生单后两种数据的差异,让卡片无感知差异,在以后有新的业务如果数据类型不同也可以遵守这个接口适配进来。笔者用对象适配器模式对卡片抽象出了ViewModelInterface并对两种不同的Model实现了对应的ModelAdaptor,使UI只依赖这个抽象的接口,无需关注数据到底是发单前还是发单后的,三年过去了期待的有新的Model时只需要适配这个Interface而使卡片无感知的场景也没有出现(都在后端接口适配抹平了,这也让笔者明白有很多东西视角不能只局限在自身的岗位角色,要更多的从端到端的链路上去思考问题),且由于笔者所使用的的Object-C语言没办法为接口添加默认实现,整体使用起来还是比较蛋疼的,这里要标记成一个过渡设计。
  5. 通过寻找共性并基于此的整个View做了抽象和拆分,将每一个可复用的子View单独拆出来,在整体的View层设计了一镜像的状态模式。全部由数据驱动,不同状态分别根据数据组合出对应的页面展示出来,完整多状态复用同一个View的能力。看似符合我们在变化章节提到的让易变化的上浮,单元更内聚,变化只影响自身、只需关心不易变化部分提供的接口而更少的去关心与其他易变化部分的关系。但为了完成一个View在多个状态下的复用整体设计的太复杂了,认知成本太高了,且相对第1条的落地场景UI是经常要(每个迭代)改动的,总要去平衡修改对框架整体的影响。导致在我交接后迅速劣化成了一条恶龙,每个要修改这里的同学都叫苦不迭,估计心里一直在MMP,嘴上还要客气的说麻烦帮我看下我想改下这里要怎么弄。这点很符合恶龙章节第三条的设计本就不利于修改,在需要频繁修改的场景不应该投入太复杂的设计,还记得当时技术评审时我的leader轻声问我:要不我们冗余一下呢,每个状态都弄一个单独的UI卡片展示?被我当时用很多看似正确的巴拉巴拉的理由拒绝了(现在想想能够通过技术评审可能也是leader对我的"溺爱"吧),还好影响面有限,在后续UI改版中就被重构掉了。

不同APP中超级页面的复杂点都大不相同,很难沉淀出一套通用的设计标准,且UI本就是要经常修改的,本身不利于过于复杂的设计,大多数的开发同学的工作主要还是画UI,很难积攒出意识和经验,这也是设计很难在移动端绽放光彩的原因吧。

一点感悟

在一点理论章节笔者已经说了很多方法论和感悟了,总结下来最重要的还是"盐放少许",能精准拿捏每个地方设计的度是多少,但如果你还没有能力达到这个程度的时候,要严格遵守三次原则、KISS原则,不要过早的使用设计这把利器,没有设计比过度设计对团队造成的伤害更大,过犹不及。但如果我一直不设计,就永远找不到放盐的感觉,只能一辈子当个学徒纸上谈兵么?其实有很多方式能帮助我们找到放盐的感觉:

  1. 多去参加公司内的大型项目,关注我上面所说的所有细节(技术评审、项目周期、设计的取舍等等),并持续关注它后面的演进与真实的效果。
  2. 多去看开源的知名项目,用你掌握的理论知识去分析揣摩作者为什么要这么设计,像侦探一样对作者测写,把它整理出来与其他人分享交流。
  3. 思从深行从简,平时做需求的时候多去像团队大牛请教,这里这么这么设计行不行,有哪些好处,有哪些坏处,不断地在脑海中演练,即使没有落地也能得到收货。
  4. 当你觉得自己ready了,去挑选一个影响面有限的场景落地你的设计,做好设计评审,讲清利害价值,再从时间中获得感悟。

碎碎念

这篇文章从有想法到真正落地时间超过1年,一是工作比较繁忙,二是2甲2阳加上长久的后遗症,体力状态极差恢复期也用了1年左右时间(身体才是革命的本钱啊😣)。更重要的是我在不断地对我学习过的知识进行归纳整理,想呈现出更专业的理论知识与经验感悟让每一个读者都能有所收获,期间每个章节都反复修改过多次,最终才落地了这篇7k+字的文章。对我的收货也是巨大的,自身的所见、所闻、所学、所悟完成了一次系统性的总结,且享受到了次写作的快乐。

行业太冷了,希望大家都有饭吃🤧🤧

相关推荐
工业甲酰苯胺4 小时前
分布式系统架构:服务容错
数据库·架构
Java程序之猿6 小时前
微服务分布式(一、项目初始化)
分布式·微服务·架构
小蜗牛慢慢爬行8 小时前
Hibernate、JPA、Spring DATA JPA、Hibernate 代理和架构
java·架构·hibernate
思忖小下10 小时前
梳理你的思路(从OOP到架构设计)_简介设计模式
设计模式·架构·eit
liyinuo201712 小时前
嵌入式(单片机方向)面试题总结
嵌入式硬件·设计模式·面试·设计规范
aaasssdddd9614 小时前
C++的封装(十四):《设计模式》这本书
数据结构·c++·设计模式
T1an-114 小时前
设计模式之【观察者模式】
观察者模式·设计模式
思忖小下16 小时前
梳理你的思路(从OOP到架构设计)_设计模式Factory Method模式
设计模式·工厂方法模式·eit
一个儒雅随和的男子16 小时前
微服务详细教程之nacos和sentinel实战
微服务·架构·sentinel