软件开发为啥强调“单向数据流”
Bug的出现
先来看1个笔者在实际开发中遇到的Bug。
因业务需要,需要在某个页面创建很多输入框(UITextField),来接受众多不同类型的参数,如下图:

等用户填写完整所有数据后,点击“完成保存”,程序会在此时将输入框中的数据逐个写到一个data model上,随后再做数据持久化,如下图:

需要提到的是 dataModel 以及 众多输入框控件都被同一个视图控制器持有。为什么不在输入框完成编辑的时候通过delegate立即将数据变化同步到dataModel?因为页面上的输入框实在太多了,一个个的都通过delegate来传递数据给视图控制器,意味着在delegate里要出现大量的if-else分支来判断数据变化是来自哪个输入框,这将是异常笨拙凌乱的代码,所以作罢。
索性编辑的时候就让用户爱怎么弄就怎么弄,我们等最后完成保存的时候再统一将数据从UI控件中更新到dataModel,之后爱干嘛干嘛好了。怎么样?这种做法看起来是不是非常nice,省时省力不折腾😃?
万万没想到,bug出现了。
它的症状是这样,无论用户修改多少个UI输入框中的信息,点击保存后,最多只有1个变动能够被成功保存,有时甚至连1个变动也没法保存!
原因分析
在花费了几个小时的盘查分析后,终于发现了问题的原因所在,如下图:

图中的refreshUI()函数,作用是将dataModel的各项属性数据应用到UI上,包括那些众多的输入框。而这个refreshUI()函数本身是没有任何问题的,之所以出问题,是因为千不该万不该地在dataModel的 didSet() 方法里去调用它。
这个didSet方法是Swift语言提供的一项便利语法糖,作用是某个属性被修改时会触发调用,然后开发者可以在里面做些想要的事情,如保存数据之类的,类似OC中手写的Setter方法。
笔者在构建该页面的时候心想,既然页面要呈现传入的dataModel,那就索性在dataModel发生变化的时候去调用refreshUI刷新各种UI控件,这样多好,关键是有现成的didSet方法可以用!
于是就出现了上面提到的问题,dataModel本质上是Struct,里面的属性propA, propB, propC ....对应着UI上的输入框A、B、C....。设想用户修改了众多输入框信息后,点击保存,触发统一的写数据逻辑writeInfoToData(),在该方法运行到第一行代码时
data.propA = textFieldA.text
该dataModel的didSet方法被触发,进而refreshUI()被触发,其中的代码被悄无声息地执行
textFieldA.text = data.propA
textFieldB.text = data.propB
...
老天!data.propB可是修改前的旧数据~
这样执行下来的结果,就是输入框B,C,D....中的内容都被强制更新成了用户修改之前的旧值,等于用户的修改除了输入框A,其他的全部被强制撤销了!
怎么补救?
可以看到,造成问题的原因在于,笔者尝试在dataModel数据变动的回调方法(didSet)里去刷新UI控件,之后企图在用户点击保存的时候从UI控件中将数据写回到dataModel,而这个写回操作又会触发dataModel的数据变动回调方法,形成了相互调用。进一步讲,数据一会儿从dataModel流向UI控件,一会儿又从UI控件流向dataModel,最终酿成了Bug。
加上执行完writeInfoToData()后,页面就立即销毁了,看不到输入框中的修改值被强制重置为旧值,这又增加了发现Bug的难度。
最后的解决办法是将refreshUI从dataModel 的didSet方法里摘除,放到ViewDidLoad中执行,随后问题消失了。
感想
之前做RN项目的时候,官方文档一直在强调一个术语“单向数据流”(貌似搞Web前端的朋友对这个概念更熟悉,奇怪App端开发知道这个的并不多)。但当时也只是按照规定做事,具体为什么要保证“单向数据流”却没有弄清楚。这次算是真的碰到了实际Bug,才知道单向数据流之于程序稳定性的重要,因为不这么弄容易出Bug。
再有就是,新的高级语言会提供很多语法糖之类的东西,使得相对于之前的语言,会有很多非常省事方便的操作来使用键值观察、信号传递之类的特性。这就导致很多开发者滥用这些语法糖、操作,因为它们可以让代码写的更少,相对于开发者老老实实地用代理,自定义函数方法来实现。
后者虽然繁琐,但调用逻辑清晰,利于代码维护和Bug定位。而这又得扯到开发效率和代码健壮性之间的平衡,是另外一个话题了。
P.s: 用作Bug分析的源码可以从下面的地址获取:
https://gitee.com/BeiTianSoftware/bugdemo.git