随着做程序员的时间越来越久,经历过快速跑业务堆需求,经历过做基础做框架搞平台搞提效,越来越觉得代码的可维护性不是个非好即坏的状态。有复杂的各种输入影响代码的最终状态(指可维护性、后略),代码的状态又进一步影响项目,最终在当前项目这个整个大的研发系统下达到一定的平衡。这个平衡状态可能不是大家想要的状态,但是一般这种平衡却其实是较符合项目现状的稳定状态。如果没有较大的外力来影响系统,这个平衡是比较难打破和改变的。只要不出现项目无法维护的情况,那它有可能会一直持续下去。
今天我想就这一些状态、影响和平衡,过去这些年产生的技术、框架解决的一些问题等,聊聊我的思考。
之前我常常觉得项目的代码越来越烂了,那一定是团队里的人太菜了。如果给我十几个牛x的程序员,甚至再给兄弟组和上下游都安排上xxx之父级别的程序员,项目代码里的各种问题一定都会迎刃而解。但是这是真相吗?
首先现实的问题就是,招不来那么多牛x的人。而且牛x的人大部分也是从菜鸡成长起来的,为什么我们的环境就培养不出这样的大神呢?
其次很重要的一点就是,我们维护的项目不是在车间里可以反复停机调试的机器,然后出厂就是最终产品。它更像是在高速运行的车,我们要边跑边升级。最早的时候它可能是自行车,用户少功能简单,偶尔还可以停下来修一修。到后面逐渐变成跑高速的汽车,我们要在它疾驰的途中不停的替换零件,在不停止运行的前提下逐渐将它升级成高铁动车。这是一件很难的事,能维持汽车不减速就已经很难了,至少现在的汽车工业还做不到不停车就能换车轮🐶。
还有一个难点,我们所写的代码不光是面向现在的需求,还要面向未来的扩展、升级。哪怕我们神机妙算如诸葛丞相,能预料到1-2年后的需求并预留好扩展口,也很难应对3-5年一轮技术革命后的新需求。更何况现在技术发展这么快,GPT都半年升个大版本,1年后的需求会是什么样就基本已经无法预测了。
总体来说,完美状态的项目只存在于梦里。在现实里开发项目,就只能尽可能努力,让项目的平衡点尽可能倾向对自己有利的一边。更加实际的可做项是:立刻去做,先行动起来。每天思考思考怎么样可以优化可维护性(学习和思考),每天把能看到的可优化点一点点做掉(行动)。遇到困难,觉得都怪队友菜是甩锅,觉得反正也好不了就摆烂是逃避,都没法切实的真正对自己产生好处。所以不如直接去做,做的途中自己有收获就不亏,受到认可项目也成功那就是双赢,心态放好才能笑到最后。
软件开发有一个很大的难点,就是需求的分工和代码的分工很难完全匹配一一对应。举个例子:
需求是给IM软件加一个会议邀请功能。参与的研发有日历团队、会议室管理团队、音视频会议团队、聊天功能团队等,这是一个需求对应多个功能模块的情况,团队一多就需要额外的沟通和对接成本。细化到单个模块内的研发工作一样是很复杂的,PRD里一小段就可以说清楚的会邀卡片样式逻辑,研发至少需要了解聊天模块怎么添加新消息类型、对应数据库模型以及和服务端的同步逻辑、UI交互实现、对其它功能的影响等。如果这些内容交给不同的人负责,那么沟通成本会上升;如果这需求交给一个研发负责,那么他的学习成本很高,需要很久才能成为熟手。
最终在不同团队和单个团队内都会产生矛盾,核心原因就在于需求是以完整连贯的一系列UI和交互为单位,但是代码模块是按照功能类型进行划分的,从划分模式和划分粒度来看都没有办法做到两者的同步。
既然现实是这样的,我们该怎么办?其实研发的工作不光是翻译和实现需求,还要在过程中进行总结梳理、规划和抽象。直接低质量直译的结果就类似于把「Cat’s out of the bag.」翻译成「猫从包里出来了」一样。而想建成一个准工业流水线,不光是要把模块划分得足够独立、职责清晰,还得大家的划分逻辑比较一致,这样可以减少学习和沟通的成本。所以诞生了Coding Style和应用框架这样的标准,各中大型app团队也都在持续关注组件化和标准化的相关技术。拿iOS这些年的常见应用框架举例:
图片来自原文
1 | var userName: String? |
这是纯过程式的写法,写入读取的地方分散在文件各处,多了以后很容易漏改。有些封装意识的同学可能会尝试用get、set方法把变量读写和数据库绑定一下,再做个和textView.text
的自动联动。但是有了DataBinding之后,写法将被统一为类似这样:
1 | userName.bind(textView.text) |
这样的好处是写法逐渐标准化,并且相关逻辑的代码集中在了一起,使得代码更偏向人类可阅读的状态。IGList的出现也把列表的刷新逻辑逐渐标准化了,达到的是类似的效果,顺便还提升了性能,那它不火谁火?唯一的坏处是,从MVVM+RAC开始,代码的学习和调试难度变高了,如果有不懂原理的人瞎写代码容易出现极难排查的问题🐶。
fromPage
参数,又该怎么做耦合才能更小呢?没有约束的话又会变成百花齐放的状态,慢慢的我们发现需要一个跨页面调度管理类,因此诞生了叫做Coordinator或者Navigator的工具类,借助router或者依赖注入等方法做到数据传递和解耦。于是我们又得到一个新的标准,页面跳转或者跨页面传递参数应该用Coordinator。总体来说,框架发展的过程中,出现了一些简化代码梳理代码的工具,也树立了代码该怎么写、该写哪儿的标准,很大程度上帮助我们把代码拆成更小的粒度,方便我们快速定位到要修改的代码,也方便我们应对各种需求的变化。反过来说,如果我们不知道代码该写哪儿,而且还多处出现类似代码,那么是时候思考一下要不要搞新的工具和标准化了。不过这些都要结合团队的实际状况来看,没有工具支持或者标准尚未立清楚的情况下就开始强推新框架,一般会适得其反。
框架、组件化和标准化的推进,使得我们按照框架标准去写代码,就会得到相对易理解易维护的代码。直白点说,这些方案是为了限制并牺牲大家的一部分自由,以此来提高代码健康度的下限。但是有些逻辑是在标准的定义范围之外的,又或者说我们想进一步提高代码健康度的上限,这时候该怎么判断我们写的代码是好还是坏呢?
生活里的例子比较好理解,比如手机的蓝牙坏了应该还能用才对,喇叭和屏幕之间也应该相互独立不影响,大家见多了也就知道怎么样是合理的了。有些人对代码有良好的感觉,看到代码就能靠直觉判断代码的好坏,跟我们判断生活场景一样简单。但如果没有这种感觉,是不是就没有做程序员的天赋?我可以肯定的说绝对不是,至少程序员是可以靠努力提高实力的,一些简单的面向对象开发原则就能帮助我们判断代码的好坏。
图片来自原文
单一职责就是类似上一节里说的内容,一个类的功能越单一越明确越好。手机的喇叭应该只负责播放声音,屏幕应该只负责显示画面,相互独立没有耦合,总不能调节音量影响了屏幕亮度。iOS开发里最容易不满足单一职责原则的类就是ViewController了,不知道该写到哪儿的代码都被扔到了这个类里,我们可以做的就是尽可能只在Controller里写一些UI布局和组织其他模块的代码,让它的功能逐渐单一化。
里氏替换就是指子类可以无缝替换父类,而不造成系统的异常。要实现这个原则有些小tips:
比如最常见的不满足里氏替换原则的继承关系:
1 | class Rectangle { |
比如现在有父类为鸟,基本属性有脚有翅膀。子类为鸽子,有脚有翅膀,而且会飞,这就是一个正常的满足里氏替换原则的设计。但是如果在父类直接定义鸟类会飞,身为子类的企鹅就傻眼了(子类删除了父类功能)。合理的做法是定义一个Flyable
扩展协议,让会飞的鸟实现这个扩展协议即可。
又比如现在有父类汽车,子类为传统油车和新能源车,这个设计是没毛病的。但是又实现了一个所用能源
方法如下,要是以后万一出来原力汽车咋办?
1 | if 汽车 is 新能源车 { |
当然,针对里氏替换原则所指向的继承方案,我个人更推荐组合而不是继承,从根本上减少不合理继承带来的困扰。
用简单直白一点的话说,接口设计喜欢小而独立,不喜欢大而全。大家有没有想过UITableView的delegate和dataSource为什么要分开?再用上面的鸟类来说,不适合在基类直接定义isFlyable
、isSwimmable
,也不适合直接定义FlyableAndSwimmable
这样的复合接口,而适合分开定义Flyable
、Swimmable
两个接口,因为他们本来就应该是分开的,不应该相互关联并强制使用者去实现他们不需要的功能。但是反观我们在开发的过程中,为了快速方便的实现功能,喜欢给一个类增加超多的方法和属性而不进行分类,从而形成破窗效应让代码状态持续恶化。
依赖倒置不光是个原则,也是个实现优秀设计的核心方法。上文提到的组合优于继承,解耦合并实现接口隔离,其实都是能用依赖倒置的方法实现的。依赖倒置的核心就是模块之间不应该依赖具体的实现(或者说实例),而应该依赖抽象(接口)。
比如说我现在有一个电脑主机和一个显示器一个音箱,想要展示内容播放声音该怎么写,粗糙的写法可能是这样:
1 | class Computer { |
现在我们的显示器是HDMI的,也支持播放声音,我们想从显示器播放声音,粗糙的改一下:
1 | class Screen { |
是不是总感觉不太舒服,好像写了些不该写的代码,而且以后有更多的播放设备该怎么办,每一个里面都实现一遍play(sound)
函数并增加变量去控制吗?所以办法就是我们不要去依赖具体的实例以及实现,我们应该去依赖接口:
1 | class Screen: DisplayDeviceProtocol, AudioDeviceProtocol {} |
以后有再多的音频设备,只要它实现了AudioDeviceProtocol
,我们都可以直接扔给Computer
类去用来播放声音,而不用对Computer
类进行修改,这就是依赖倒置原则带来的好处。
开闭原则的要求是对扩展开放、对修改封闭。比如我们可以给手机套个手机壳插个充电宝,但是不能随便翘开手机换主板。为什么把这一条放到最后来说呢?因为能做到前面的各个原则,写出来的代码基本上就符合开闭原则了。前面举的电脑的例子里,如果没有用上依赖倒置,那么我们只能频繁修改Computer
类,没办法轻易扩展新的音频设备,这就是对修改开放、对扩展封闭的一个反例。又比如前面汽车的例子里,我们增加新类型的汽车就一定要修改其能源模块的代码,这时候就应该把能源抽象成独立的接口了。
完全遵守这些规则,代码的设计难度和实现时长一定会变长,在项目进度极度紧张的时候会很难执行。但是相信一句话叫做磨刀不误砍柴工,对于需要长期迭代的工程来说这些都是值得的,想办法让你的老板和上游也了解并相信这一点。还是前面那句话,至少要尽力争取,让代码状态的天平尽可能向自己倾斜,摆烂只会让自己的工作状态越来越糟。
又或者说,就算这个项目做不下去了,自己也要学到些东西好在下一个项目大展拳脚吧🐶。
写了一堆,总结起来也就是:
祝大家都能有强大的实力和容易实现的需求,也祝大家新年快乐🎉
]]>这一年一如既往的很多不如意,时间依然不足,也依然没有回本。
不过这也是幸福的一年,最大的收获就是认识了可爱的公主并走到了一起,虽然独处的自由时间越来越少了,但是这波明显不亏对吧?
好看的动漫越来越少了,看动漫的时间也越来越少了,中年男人的泪目……
《JOJO 石之海》:趁着春节才把石之海补完,个人觉得这一部更多的强调了命运,虽然在设定上让人觉得越来越牵强,不过依然好看。我渐渐认识到很多命运不可改变,不过我同时也认识到无论在什么样的命运里,我们都该有选择自己生活的权利和勇气。不知道荒木是不是也经历了什么命运,有了这样的灵感,想让我们有挑战命运的勇气。
《灵能百分百》:重新回味了前两部,不愧是神作。第三部还没看完,不过依然是如此燃且有深度有感情。
《鬼灭之刃》:优秀的作品,就是B站和谐的🐴不认了都,🐶都不看,网盘还能免费提前看两三集呢。
《夏日重现》《命运石之门》:看完夏日重现,顺带补完了经典老番命运石,命运石的节奏太慢有点跟不上现在的快节奏了,不过结局是真的神!
《更衣人陷入爱河》:十几年过去了,依然喜欢纯情高中番……
《间谍过家家》:吃吃瓜的作品……
《三体》:正式宣告烂尾,这都啥……
《三体》:刘备演的不错!希望不要后面掉链子。
《扬名立万》:层层反转,有笑有泪,好作品。
《风平浪静》:有些路,走上了就是不归路。
《隐入尘烟》:这就是偏远地区很多人的真实生活吧,或许会更糟。希望世界对善良的人能好点吧,也想到了“二舅”。
《梁祝94版》:港片还是强啊。困住他们的是整个时代,我们何尝又不是被时代困住?
《武侠》《道士下山》《师父》:经抖音推荐,不错的武侠题材剧,还行。金城武悟到了什么,王宝强又悟到了什么,廖凡又为什么要继续踢馆,还是值得思考一下。
《熔炉》《狩猎(丹麦)》:地狱空荡荡,恶魔在人间。那也不能阻止我们活下去,阻止我们坚持正义。
《楚门的世界》《末代皇帝》《本杰明巴顿奇事》《心灵捕手》,重温《记忆碎片》《禁闭岛》:不愧是豆瓣250。
《情书》《居家男人》:偶尔恋爱脑一下,符合我的作风。
《邓布利多之谜》:emm,男人和男人才是真爱,好盖好喜欢!后来才发现这不就是狩猎男主!
掏出来几部老剧看了精讲。《八号当铺》剧情还是很带劲的,不愧是当年大学里的热门剧。看了下《吔屎啦梁非凡》(忘了剧名了,就这样吧),九姑娘无敌!《神探夏洛克》很适合我这样的悬疑推理剧爱好者,就问夏洛克和华生什么时候在一起?《半泽直树》感想一般。
啊,今年真的荒废了。投资书籍就只看完了《量价分析》和《笑傲股市》。小说看完了《活着》,感受到生存之艰难和活着的力量。然后《三体》第一部看了大半,希望后面能跟上电视剧的进度。剩下的一堆书要怎么赶上进度啊喂,焦虑!
更是荒废的离谱,NS一年多没掏出来了,Steam也就杀戮尖塔刷了个全奖杯。
一些刚买但不知道什么时候能有空玩的游戏。
一些感觉买了也肯定没时间玩的游戏。
一些买了一两年都没玩的游戏。
有时间的时候没钱,都舍不得买游戏玩。有点钱了之后可以买一堆游戏,然而再也没时间去随心所欲的沉浸在游戏世界。什么时候才能有时间呢?
希望兔年可以兔然有时间,继续我所热爱的游戏。
封闭了两个月后彻底想开了,放飞自我。年底🐑过🐑康之后更是无所谓了,继续放飞自我。
今年和A老师一起去了长沙和南京,打卡各种美食圣地。
长沙是个强力推荐吃货朋友们去的地方:好吃的黑色经典!好喝的茶颜悦色!好玩的超级文和友!好逛的长沙夜市!
去南京去得太匆忙了,尝到的美食不多,也可能是刚🐑康后体力和味觉都不太跟得上,有点可惜。
工作越来越忙,陪女友、带孩子又要时间和精力,累到没精神没时间维持持续健身了。
🐑完之后更是持续了一个月都乏力、胸闷、易困、胃不适。希望23年至少可以健康一点吧,人不难受就行。😭
没想到的一点是,去年的图还能接着用:
如果没有年终奖,真的又是一年打工赚的钱大部分补贴给了股市,基本等同无效打工。有时间的时候市场行情不好心态不好,瞎操作爆亏;行情好的时候又没时间,错过了几次好机会;最离谱的一次是边打游戏边操作美股,下错了单导致打完游戏回来看的时候已经爆仓了……
emmm,和JOJO里说的一样,命运吧。好在不是全部家当放在股市里,还是得先继续学习提高,再开始做全职炒股的梦。
实在没什么好总结的了,只希望23年可以慢慢走向更美好的生活,和心爱的人一起共创未来。
]]>这一年失去了很多,感慨下终归还是没有办法调节好生活中所有的平衡。当然也和大部分中概人一样:
不过这一年也学会了很多,有所付出终归有所收获。时间越来越不够用,原定的计划做了不少调整,不过总体来说还可以接受。
《白夜行》:光明的背后总有黑暗
《人类简史》:很有趣,能从生物、经济、政治等各个方面全面分析人类历史的书可不多,推荐对历史有兴趣的朋友阅读
《围城》:日子真正过起来和开始过之前,差别是真的大
《小狗钱钱》:财商入门,小朋友都能看,适合用来入门并判断自己对理财到底感不感兴趣,对我来说太浅了
《财富自由之路I》、《富爸爸穷爸爸》:理财入门+实操建议首选,之前在公众号就推荐过
《动物庄园》、《1984》:只能说久仰大名,读完后恨读得太晚,说得再多我号就没了
《股票作手回忆录》:利弗莫尔的传奇人生,从中还是学到了不少,值得搞投资的朋友一看
《非暴力沟通》:沟通是人与人交流的核心重点,怎么表达自己也是要学习的学问,感慨看得太晚
《海龟交易法则》:如果你在尝试寻找一个交易模式,看这本,能解除大部分疑惑
《证券市场基本法律法规》、《金融市场基础知识》:把这两本教材也算上吧,今年也通过了证券从业资格考试,为以后IT业下岗留条后路哈哈。我个人对经济知识还是比较感兴趣的,对法规确实是头疼的不行,每个人都有自己喜欢的方向吧
这一年的读书目标完成的还可以,明年还要继续努力(争取回本)
《JOJO石之海》、《岸边露伴一动不动》:JOJO,永远滴神
《动物狂想曲》:因为动画,顺带成为了YOASOBI的粉丝
《Re:0》:就问什么时候出下一季?雷姆什么时候醒?
《装甲铁拳II》:其实这是个音乐番
《星际牛仔》:浪漫至死,至死浪漫。现在的番,都没有老番的那种味道了
《元龙》:导演我xx你xx,我做错了什么你让我看这玩意,xxx退钱
《鬼灭之刃》:大哥永远是你大哥,这么溜的番还没看就枉为二次元了吧?
《国王排名》:这画风一言难尽,好在比较温暖,可以给灰暗的心带来一丝光明
也可能是新番越来越拉垮了,也可能是时间越来越不够了,看的番越来越少了。至少几个热门番还是尽量跟上了进度,证明我还是个二次元人。
《第九区》:还可以,虾虾是虾虾,人类是真的狗
《失控玩家》:一般般啊,这就掀起了一波元宇宙热潮?
《一步之遥》、《让子弹飞》:看完一步之遥又刷了遍让子弹飞,姜文牛逼
《被解救的姜戈》:爽就完事,值得一提的是:背叛阶级的叛徒死得越惨越好,比如奥利奥和工贼
《肖申克的救赎》:优秀的人,努力的人,终将走向更美好的人生,自勉
《十二怒汉》:这个群像刻画的太好了,值得反思一下,我们自己更像里面的哪一位呢?
《沉默的羔羊》:虽然没完全看懂,但我大受震撼
《未来的未来》:emm,日漫有时候节奏确实太慢了……
《树先生》:想不出来什么好的评价,有空得看看他别的作品
今年只投了15个视频,粉丝几乎没有增长。废话,15个里面大部分都是水视频,怎么可能有增长。
感慨下时间越来越不够用了,所以只能在目标里做调整。游戏都没多少时间玩了,哪儿还有可能做视频。
做视频其实真的是个很累的事情,做过才知道那些Uper的不容易。一个30分钟的优秀游戏视频(非实况录像那种),玩游戏可能要玩好几个小时,剪辑又得是几个小时,还得有优秀的创意。
头部终究是头部,创意不够亮眼,没有专业团队和时间小Uper,还是有点难的。怪不得都说开始渐渐走向pugv了,草根网红渐渐变少了。
渐渐的放弃这条路了,除非旷野之息II发布,我可能还会再诈个尸吧。
排除部分闲聊和水文,发布了几十篇内容,中间十月因为备考基本没更新,其它月份基本满足每月三四篇的目标。
理财的入门知识还有基金的选择知识已经算是整理成系列了,今年一直隔段时间就投一点的两只核心基金还是小赚的,成果还算不错。
说起来对投资知识内容,推荐力度最大的居然是富途牛牛,其它平台竞争还是太激烈了[狗头],完全get不到怎么获得推荐。一年下来也就攒了几百个粉,活跃的估计也就几十个,是有点打击信心哈。
不过既然目标是整理输出自己的观点,粉丝这种东西当作附属品就好。
股票的投资在2021还是犯了几次大忌,回本之路漫漫,调整下心态继续慢慢来吧,这种事越急越容易接着亏。
反而是一些听我分析没摸中概的朋友,赚了不少。这算是医者不自医吗?总结起来还是知行不合一,战胜市场更多的是要战胜自己的人性,一点不假。
后面我开始仔细研究股票投资模式了,有成果再进一步分享,整理成系列。
健身是真的不能断,中间断了两个月,差点直接废了。
体重:69.4 -> 69.6 -> 69.7
体脂:16.7 -> 19.9 -> 18.6
体重一直没有达到目标,几乎没什么波动,但是其实体脂突突突的飞升了一回,就别提什么腹肌和引体之类的目标了。算了咱还是实际点,先保证健康吧。总归是恢复了锻炼之后稍微向好的方向走了,明年继续努力。
这一年是我开始做出改变的元年,真的开始努力了之后才慢慢的发现以前浪费了那么多的时间。不过娱乐活动还是要保持的,在所长的课程里我也了解到了娱乐是人类的正常需求,吃吃喝喝也类似,长期克制自己的需求反而会导致状态变差。以后的目标是,尽量控制到一个平衡吧。
2021在人生方面两个最大的感悟就是:学习知识才会持续进步,付费知识其实是最高效高质的(虽然过程中可能会遇到些许骗子)。
今年继续多读几本书,保持一个健康的身体,然后才能熬到回本对不对?冲!
]]>还有一个重要的方向,就是删除无用的代码。不过这个方向在技术上还是有些难度的,因为OC这门语言的特殊性,很难使用静态检查的方法找出无用代码。那么就要借助在运行时实时分析的方案来做,分析的粒度大小主要分为函数粒度和类粒度。按照函数的粒度来分析,要么是在runtime里hook msg_send
相关的入口,这样做对性能的影响蛮大;要么是深入了解底层原理后,在编译期做好插桩工作,这样做需要对相应底层技术有十分深入的理解。总之,要么很废,要么很难。所以我们这次利用runtime自带的特性,从类的粒度来分析一下类有没有被初始化过即可。
很多技术博客里都提到了,ObjC类的meta class里,自带了一个标记自己有没有初始化过的flag。但是这些文章大部分都是点到为止,没有更深入的介绍具体的实践方法,今天我在这里就整理并提供一个可以实践的方案。
我们要做的就是利用runtime扫描一遍所有ObjC类的列表,然后再定期扫描这列表里所有的类有没有初始化过,获得大量数据后再进行分析,就可以得到初始化次数最少甚至为零的类啦。
听上去挺简单,但是具体有什么难点要实践了才知道,那就开始吧。
当然如果你不想看下面的长篇大论,可以直接看已经放在GitHub的开源库:SLMClassCoverage
扫描所有的ObjC类其实挺简单的,一句objc_copyClassList就搞定了。但是扫描完立刻就发现了问题,一个几乎空白的demo工程,系统类的数量就已经成千上万了。
找了找思路,发现可以用[NSBundle bundleForClass:XXXClass]
是不是[NSBundle mainBundle]
来判断一个类是不是主bundle里的类。经过了一番尝试,方案可行是可行,但是又发现了新的问题,这个NSBundle是线程不安全的。扫描大量的类是个耗时操作,直接扔在主线程来做可不好。
好在有了思路就不难办,仔细想想runtime的class_getImageName也可以完成类似的效果,记录下主bundle对应的image再进行比对就可以了,实验了下效果也很不错。
整理完app内用户自有类列表之后,梳理数据的时候又发现一个小问题。因为ObjC这个语言是动态的,可以在运行时动态添加类,一些observer操作还有RAC操作容易产生一些动态类成为干扰数据,这就有点难办了。有一些这样的类的类名是下划线开头的,我就顺手把这些类给屏蔽了;其它一些难以判断的,就只能加一些保护防止崩溃了。(如果有什么好的idea欢迎讨论)
至此,我们把要扫描的类列表就能整理出来了,详细的代码可以参照上面的源代码。
ObjC runtime的开源代码真的是一个好东西,因为C系列语言指针的自由度,了解runtime的结构后基本上可以完成各种各样的骚操作。咳咳,当然骚操作不建议瞎用,除非你真的把它搞明白了,因为有时候骚操作会带来很可怕的副作用。
ObjC 类的结构主要是在objc-runtime-new.h
这个文件里定义的,下面是这个文件最新两版开源代码的地址:objc4-787.1,objc4-818.2
以后面这个最新版为例,先打开文件,找到下面的内容:
1 | // objc_class 代码片段 |
这个objc_class
的isInitialized
就是我们要用到的方法了。但是很可惜,这些方法对我们app来说,都是不可见的。所以在这里我教一下大家,怎么分析出我们要用到的变量的偏移地址,直接用指针来搞定问题。
从方法的调用情况看到我们要先找到类的meta class,这个是个runtime里可见的方法,直接调用就能拿到。然后回到objc_class
的头部几行:
⚠️ 注意:为了简化我们的问题,我们只考虑64位架构处理器的情况。毕竟iOS在11.0以后就木有支持32位的CPU了,多研究也没有很大价值。
从之前那段objc_class 代码片段
可以看出来,objc_class->data()
方法取到的就是头部定义的这个bits
变量里的data()
,我们最终要找的就是怎么拿到这个值,也就是先要找到上面截图里横线上这个成员变量bits
的内存偏移地址。
runtime源代码是用C++来写的,函数都不会占用类meta信息(或者说实例)的内存地址,只有成员变量会占用地址。什么意思呢?看下面的图:
这个就是objc_class
的父结构体,具体代码在objc-private.h里。以这个结构体为例,isa
这个成员变量会占用类实例的头8个bytes,也就是64位CPU的一个指针的长度;而下面的这堆方法,在实例里面一点内存都不占用。然后看完整个结构体,只有这么一个成员变量,所以总长度也就8个bytes。
回到这张图里,对于子结构体来说,是需要继承父结构体的成员变量的。所以objc_class
在继承objc_object
时,就带上了父结构体的8 bytes长度的isa
指针变量。
objc_class
内前面几行那几个函数禁用语句,不会产生内存地址的偏移,先不管它们。
superclass
作为一个指针(对,Class
类型实际上是一个指向结构体的指针),长度也是8 bytes。
然后我们发现一个不太认识的cache_t
类型的变量cache
,它占用了多少bytes我们就要展开看看了:
首先说一下explicit_atomic<T>
,它只是负责把模板里的类型包成atomic的,不会对变量占用的内存长度产生影响。
然后要说一下union
,C++的老朋友了,联合体内部的成员取最长的一个内存长度,就是联合体的内存长度。
mask_t
的定义在文件头就能找到,其实在64位CPU下就是一个uint32_t
而已。
基于上面的介绍,可以推断出整个cache_t
结构体的长度就是16 bytes(详情参照上图标记)。最后得到:
接下来就是要分析bits.data()->flag
的位置了,很凑巧的是:
1 | // flag 取法代码片段 |
bits
和flags
都是两个结构体里的第一个成员变量,那么我们就不需要继续计算偏移了,留意下data()
函数的具体取值方法即可。
最终我们从一个Class类型,取到它有没有被初始化过的代码大致如下:
1 | Class metaCls = objc_getMetaClass(class_getName(cls)); |
这个isInitialized
的取法完成了,但是它只是数据收集开始的第一步。
扫描类有没有被初始化过是个很快的操作,但是扫描出整个类列表的过程很慢,主要是class_getImageName
很慢,所以我在开源库里做了一些简单的缓存策略。这点需要大家自己留意下,根据自己的需要自行修改。
后面基于不同项目的情况和需要,大家还得自己设计数据上传及分析的流程。
希望这篇文章对大家有帮助吧,今天就写到这里。
]]>CTTelephonyNetworkInfo.h
,还是那个熟悉的available标错导致的崩溃。因为我在公司是负责基础模块的,所以网络相关的蜂窝网络类型判断是我来处理的。
两年前,苹果第一次做双卡手机,经验不成熟,所以出了不少问题。我们当然是只能选择原谅他,然后自己背锅啦。不然呢?我们还能硬刚苹果或者逼用户升级不成?
当初和双卡相关的API,都被标记成了API_AVAILABLE(ios(12.0)
,但是其实这些函数都是在iOS 12.1才实现的。在iOS 12.0里要么就是函数直接没有实现,要么是函数返回nil
。好在只是导致一些功能逻辑上的问题,并不会导致用户用不了app。在后来版本的app里我们只能对这些函数又做了一层保护,以防调用出什么奇怪的逻辑。
但是有一个常量就坑了:
1 | CORETELEPHONY_EXTERN NSString * const CTServiceRadioAccessTechnologyDidChangeNotification API_AVAILABLE(ios(12.0), watchos(5.0)) API_UNAVAILABLE(macos, tvos); |
毕竟常量可没有办法用respondsToSelector:
包一层,在iOS 12.0只要使用这个常量,就会导致iOS 12.0的用户崩成🐴 ……
被苹果教育过的我们,给使用这个常量的地方都包上了if (@available(iOS 12.1, *)) {}
,并且加上的长长的注释说明这个常量在iOS 12.1才能调用😭 。
iPhone 12终于支持5G了,用户反馈app没有正确的识别5G,当然是要尽快处理用户的问题啦。
扫了眼CTTelephonyNetworkInfo.h
果然发现有新的网络制式(下图为Xcode 12.1):
果断给加上对应判断,拿台iPhone 12配合5G测试一下,完美无瑕!
然后,然后研发和灰度用户们就崩成🐴 了……看了眼崩溃的人都是iOS 14.0用户,这时我才想起两年前被苹果支配的恐惧😂 。
赶紧去官网看了一眼,官网文档里甚至都还没有出现对应的网络制式枚举的说明:
然后又拿了份Xcode 12.0,看了看里面的CTTelephonyNetworkInfo.h
:
我了个去,坑爹呢这是,果然是Xcode 12.0还没有的枚举,肯定又是一样的问题。
拿实机验证了下,果然iOS 14.0其实并不存在对应的常量,调用了就会直接崩溃,和当初一模一样,熟悉的配方熟悉的味道。
叹着气流着泪,我们又多了一行if (@available(iOS 14.1, *)) {}
和一串长长的注释,希望大家看着这篇文章引以为戒,不要相信苹果的沙雕available标记。
2年前的文档错误苹果看来是不打算修了,这一次的API标记错误我们也向苹果反馈了,看看苹果会不会处理下,避免广大开发者踩坑吧。
愿苹果开发者和用户能有一个美好的未来~
又想起来一个事情要补充一下,因为今年的iOS 14.2 beta和iOS 14.0发布时间是有重叠的,所以貌似iOS 14.2的前几个beta是没有对应常量的。
从官方文档来看,iOS 14.2 beta 3才开始有对应的常量:
但是我们的兼容代码只能写if (@available(iOS 14.1, *)) {}
,所以想必还停留在iOS 14.2 beta 2的用户也炸了吧……
苹果的beta是真的不能用,还望大家珍重,早日升级系统新版本~
]]>无论做什么事情,都是始于学习的。技术这个行业更是要求很强的学习能力,每天都在更新各种各样的新技术,每一项技术也都可以研究得很深入。
当然一开始还是先把基础打好。当年在学校的时候总会想,学习一些计算机原理、编译原理之类的知识,对以后做软件开发的帮助应该不大。但事实上了解一些相关基础,对于思路的拓展和理解更深层次的技术,还是会有不少帮助的。另一些算法与数据结构、面向对象开发之类的知识就更不用说了,都是基础中的基础。
然后就是一些经验类和通用类知识的学习,推荐一些经典书籍:《程序员修炼之道》、《设计模式》、《代码整洁之道》等等。书籍基本上都是沉淀了各位大师毕生的经验心得,真的把这些都理解吃透,可以少走很多弯路。第一遍阅读时可能会无法完全理解书中奥秘,过两年再回味一下或许就会有新的收获。
关于什么样的设计算是好的设计,推荐大家可以了解下SOLID设计原则,这算是最精简的总结了。总之一个设计方案拿出来对比着SOLID设计原则来看,不符合其中的部分原则,那设计应该是存在问题的。
最后要学习工作方向的专业知识和相关知识,通俗来说就是深度学习和广度学习。在深度上一路磕到底,在广度上放飞自我,两种极端都是不行的。在深度上耕耘可以让我们胜任自己专业范围内更高难度的工作,但是光在深度上钻研容易进入一种井底之蛙的状态。比如我们写iOS端,完全不懂web端、服务端和Android端,遇到app需要内嵌web页面进行js-bridge交互的时候怎么设计方案?遇到dns污染如何解决?怎么保证移动端多端逻辑和行为一致?到最后只能变成一个无情的按照需求实现逻辑的代码机器。在广度上适当耕耘可以拓宽我们的思路和视野,甚至可以造些实用的“玩具”。比如学习了Apache Kafka的设计思想后,我们可以把思路应用在客户端做一个消息队列,这对消息/通知集中式管理的系统(比如一些聊天软件)很有帮助。比如学习了python之后,可以做一些自动化构建脚本,提醒写周报的机器人等等。但是在广度上不经筛选的学习并且过度发散,也是得不偿失的。我见过有人说自己会搭建十几种服务器,会写近百种语言的hello world,也不知道怎么评价比较好……
所以总结起来,学习这事情需要我们投入精力,而且要规划权衡好学习的方向。大公司可能需要在自己的岗位上更专业深入些,其它的工作由同事来配合完成;小公司分工没那么明确,可能更需要能力多面的“全栈工程师”。但是小公司会发展成大公司,大公司的各位也可能自己去创业,世事无绝对,要考虑好未来。
JSON和ProtoBuf,两大数据格式大家应该都比较熟悉了。如果让我们选择用其中一种来作为我们和服务端进行数据通讯的格式,应该选哪一个呢?很多人会觉得,PB是比较新的格式,体积更小,类型确定,兼容性和性能也很棒,用就完事了。但是抱着这种简单想法就直接选方案是很危险的,我们选择方案应该仔细的考虑哪种方案对我们来说更适用。PB体积是小了,但是采用二进制格式也会带来些问题:
所以说,我们要考虑的是:
可以看出来,如果我们的app现在用户量少,调用接口也不多,并且我们也没有能力和精力做好基础工具和性能监控的建设,那么一开始完全可以采用更简便易用的JSON。
类似的问题:bitmap、jpg、gif、png和webp等众多图片格式,我们应该选用哪一种?可能又有人会说webp动态图静态图都能支持,图片压缩比高体积小,也没有严重失真等问题,应该用它最好。但是机智点的朋友现在应该知道了,只考虑优点是不对的。
图片格式这么多,每个格式又有它们各自的特色,所以我们的选择会多样化点:
广为流传的表情包,需要方便大家存储,动态图会采用gif格式,静态图会采用jpg或者png格式。
用户进行截图的时候,会希望图片质量更高,尽量还原所截的画面,一般会采用png格式。
各种icon需要透明底色,也需要比较高的清晰度,一般会采用png格式。当然还有很多情况会采用Iconfont方案。
各种风景画式的背景图或者闪屏图,图片超级大,对清晰度要求又不高,一般可以采用jpg格式。
列表页和详情页的大量图片,一般可以采用webp格式,用以加速网络图片的加载,节省cdn带宽和流量。
可以看到,单一的一个方案已经不能满足全部的场景,所以我们才会在不同的场景分别选择最合适的方案。
等等,为什么始终没有bitmap的戏份呢?我们得想一想,为什么我们现在用图片一定要压缩呢?因为我们的存储空间是有限的,因为我们的网络带宽也是有限的,压缩再解压虽然会浪费一点时间,但是能在网络传输过程中节约更多的时间,也让我们能够存储更多的内容。那么会不会有那么一天,硬盘变得更加廉价更加快速,网络带宽也变得无限大,反而是CPU性能遇到瓶颈无法再提高了。如果真的有这么一天,那么bitmap应该会登上王座。
就像上面说的图片格式选择那样,当压缩不再能产生收益的时候,这一项技术或许会整体走上末路。技术的发展和选择都是为了产生切实的收益,有些技术不能给我们带来收益时,就需要考虑下我们到底该不该使用这项技术。
网络请求基本上是现在的app必须的功能,我们该用NSURLSession(AFNetworking)?还是更底层的CFNetwork?还是完全独立的网络库,比如Cronet这种呢?
NSURLSession已经可以满足日常的各种网络请求,但是也只是能做到很基础的请求而已。在iOS 10之前,甚至连个像样的性能监控模型都没有:
从iOS 10开始,有了NSURLSessionTaskTransactionMetrics
,但是数据项很少,直到iOS 13,才开始补齐更多的属性:
如果我们切换到更底层的网络库,就可以获得更全面的性能数据,对网络连接和请求的自定义能力也会越来越强。这无疑是增强了我们的能力,但是这些能力能给我们带来收益么?
如果我们的app就那么一台服务器,cdn也是用的xx云附送的,没有自行调度的能力,我们可以发现网络的波动和其它问题,然后呢?没有然后了……
我们利用更底层的网络库实现了智能的dns调度,避免了dns污染问题。但是我们就1000个用户,一个人都没有遇到过dns污染……
可以看到上面这些例子里,我们做出了更优秀的技术方案,但是短期没有能带来收益。不是说预先准备好优秀的技术方案是坏事,它在未来可能会迎来发挥价值的时刻。但是更多的时候,我们的资源是有限的,好钢要用在刀刃上。app发展的初期,做一两个新奇的功能可能会成本更低的带来更多的用户,更高效的促进团队发展和进步。如果把资源用在了短期不能带来收益的方案上,兴许app凉了方案都没实际派上用场,这就叫做过早和过度设计。
对方案只管上不管埋是不行的,强烈鄙视。做一个技术项目需要准备基本的验证方案,还有对不同验证结果的应对方案。
例如近几年移动端开始流行MVVM设计模式,经过初期的调研,觉得比较适合现在的团队和项目状况,可能会带来收益,那就可以开始准备使用并验证了。光觉得是个新技术,高大上,引入项目就开始吭哧吭哧的用,这种人肯定是个有毒的技术leader。简单点要跟踪下迭代效率、质量,看看同样的开发周期,是否还能平稳的完成开发任务,是否没有产生大量的bug等等。再深入点,我们要review代码,验证MVVM方案下完成的代码质量如何,是否真的按照合理的设计模式完成了代码。在有一点基础工程的支持下,可以验证使用了MVVM的页面的APM各项指标如何。如果出现了不符合预期的情况,考虑下是团队内工程师学不来MVVM,还是工程本身客观条件不适合使用MVVM,如果是人员问题可以考虑加强培训,如果是工程问题那可能需要尽早考虑更换方案,等等等等。
没有人可以做到料事如神,那么只能边走边试,在尽可能降低风险的情况下进行试验。为了达成这样的目标,会关联出更多的技术方案,慢慢的团队里会需要ABTest、分桶工具,在线调整参数的方案,APM监控平台等等。做这些又需要我们有完备的数据收集通道,上报方案,设备唯一ID生成方案等等。这一般就是诞生基础架构、底层团队的过程。能够思考得更长远,做好更万全的准备,项目才能更稳定高效的迭代下去。
总结来说,我们要做的各种各样的选择,都不应该是绝对的。每一个选项会有它的优点,也肯定会有它的缺点。就像前面提到的:用JSON还是PB,多种图片格式的选择。我们要考虑我们可以接受哪些缺点,希望获得哪些优点,综合权衡下利弊,才能确定到底该选择哪一个方案。
还有句话叫做小孩子才做选择题,成年人会说我全都要。有时候我们并不是要从选项中多选一,选项本来就会是个连续区间,所以我们是要动态的调整方案,保证能够均衡的获得各种优点。就比如说经典的不可能三角(如图),我们所有人都想要又快又好又便宜的方案,但是大家也都知道这是不可能的。最后,我们只能用各自的方法去摸索,怎样才能达到最接近“不存在”的那一块区域。在生活中也是类似的情况,比如说我们纠结今天吃什么,大家都想要吃上又好吃又便宜又健康的一顿,但是总会要在一些方面做出妥协和平衡。当然也可能有些人不在乎这些,那么对他们来说最重要的可能就是吃饱和省事,这也就是他们所希望获得的优点(奇怪的选择方向增加了~)。
用更加客观科学的方法去分析,认识到每个事物的多面性,再去做自己的决策和平衡,才是更好的。当然这其中最重要的就是自己的思考,人停止了思考那就和咸鱼没有什么区别了。
感谢阅读到这里的大家,今天的分享就到这里啦。
]]>ID类型通常用在变量名和函数名上,变量要应用的话至少还得实现赋值表达式,所以我们先用ID类型来尝试实现函数调用功能。注意是调用我们在计算器里内置的函数,暂时还没有办法动态定义新的函数。
有了上一章的基础,ID类型的解析应该对大家是易如反掌了,简单到我都懒得画状态机图了:
1 | typedef struct { |
我们在slm_token
结构体里新增了一个name
字段用来存储解析出的ID,这里的ID型token以字母开头,后面可以跟着多位字母或数字。C语言里的内存管理是要重点注意的,一定要在适当的时机释放掉自己申请的内存,我一开始也漏了一处😂。
哈哈,又回到语法分析步骤了。回到语法分析,首先要写的就是文法,这次只列出来要修改的文法:
1 | factor -> number | func | '(' expr ')' |
我们只打算支持三个函数:max(x, y)
、min(x, y)
和abs(x)
。因为这里只有单参数和双参数两种情况,所以文法就直接生硬的把所有情况列出来了。有了给定的文法,想必写逻辑也不是难事。因为懒得去弄一个新的变量暂存函数名字串,所以我直接把三个函数展开成三个if段来写:
1 | int func(slm_expr *e) |
做一些测试验证下逻辑是否正常,函数调用功能就算完成啦。到这一步的完整代码可以在SlimeExpressionC-chapter5.1获得。
不用我说,大家应该也觉得上一节的函数解析逻辑写得太丑了,这种代码不应该存在于我们的库里!
首先我们肯定不能给每一个函数写一个if段,那么我们肯定需要一个表来存储我们支持的所有函数,这样就可以在表里查询我们支持的函数该怎么处理了。然后就是要考虑函数怎么存在内存里呢?答案当然是用函数指针呀。我们先做的粗糙一点,把参数数量不同的函数定义成不同的结构体成员:
1 | typedef struct { |
这样,我们的函数表就先搞定了。把函数指针对应的函数给实现一下:
1 | int slm_abs(int x) { |
接下来的重头戏就是对func函数的改造啦,有了思路大家应该也大概能知道实现是什么样的了:
1 | int func(slm_expr *e) |
当然这代码还是有优化空间的,比如我们可以用hash表来存储函数表,加快检索的效率;比如我们可以用链表或者数组之类的手段传递参数,这样就可以动态的支持无限多的参数。不过这些就不深入在这里展开啦,目前完成的完整的代码放在SlimeExpressionC-chapter5.2。
借助函数表也是以后我们实现动态定义新函数的关键点,到时候大家把FuncList定义成可变的就好,具体深入的做法这里我也不展开了,因为那个已经超出入门课的范畴啦!
到这里我们的编译原理入门课就告一段落了。通过实现一些简单的表达式解析计算功能,我们把编译器前端的语法分析和词法分析工作原理讲了个大概。过程中也介绍了一些设计编译器的方法,大家掌握了之后应该也可以在其基础上,做出一些自己想要的功能,例如实现变量和赋值表达式等。我也只能做到领大家入个门,更深入的知识就需要大家自己再去找资料深入学习啦(前言里我也列了一些资料)。
那么希望我的入门课对你有所帮助。如果以后我的懒癌痊愈了,兴许会再写个编译原理中级课吧再见😁
(五)解析ID型词法和函数调用语法
]]>词法分析就是把一个完整的语句拆分成一个个词(token),方便之后进行进一步的语法分析。
举个简单的例子:今天真热
,将会被拆分成<今天>, <真>, <热>
。当然拆分成<今>, <天真>, <热>
也是一种可能,但是这样的分词方式不符合汉语的语法。
好在计算机语言大部分是英文的,词与词之间一般用空白符隔开,很容易拆分。举个代码的例子:a = 1.1 + 2
将被拆分为<id: a>, <等号>, <浮点数: 1.1>, <加号>, <整数: 2>
,当然我们也可以把加号和等号都算作运算符,做一定的聚合得到<id: a>, <运算符: =>, <浮点数: 1.1>, <运算符: +>, <整数: 2>
。
有人要问为什么拆分token的逻辑要做成单独的词法分析步骤,而不是放在语法分析里一起做?这是个好问题,还真的有点难回答。从我个人的观点来说主要的两点可能是:
要问得更具体的话,还是建议各位在自己写编译器的过程中自行体会一下……😂
上面也提到了词法分析器主要是用来拆分token的,但是词法分析器还要负责一些别的工作。我们总结下词法分析器的主要工作范围:
我准备一开始做的简单点,先把必备的前两条功能给实现了。
首先我们得定义一个用来描述token的结构体:
1 | typedef struct { |
关于token的类型,前文也提到了,运算符可以做一定聚合,也可以每种运算符算一种类型。我这里就不做聚合了,把我们前文出现过的token类型都定义出来:
1 | enum { |
然后扩展下我们的slm_expr
结构体,以后语法分析器就不应该直接读expStr
而应该从token
里取值啦:
1 | typedef struct { |
词法分析的核心函数一般叫做next
或者scan
,我们这里就叫它next
吧。它主要实现的功能是读取下一个有效的token,存到slm_expr
结构体的token
成员里以供语法分析器使用。
C语言的词法分析十分简单,因为根据token的首字符就能区分出token的类型:如果首字符是数字那一定是个数值token;如果首字符是字母或下划线那一定是个id类的token,至于这个id是关键字还是函数名、变量名那就另说了。怎么样?是不是突然明白了大部分计算机语言里变量名不能以数字开头的原因?
这一章里面我们暂时还用不到id类的token,所以主要讲一下数值token的处理:
1 | void next(slm_expr *e) { |
可见如果发现一个token是以数字开头的,那么我们可以循环读取后面连续的数字,直接把整个token完整的数字值读取出来,供语法分析器在后面的分析中使用。
词法分析的过程一般可以用状态机来描述,上面的解析过程对应的状态机可以用这么个图来表示:
可以看出来这种图和流程图相似,更适合用条件分支及循环语句来实现它的逻辑。然后我们可以大致的补全一下整个词法分析器的状态机图:
接下来照着状态机图来实现代码逻辑就好了,在这里不贴完整代码了。注意如果出现了用状态机无法描述的token,那么这一定是个非法的token。
用状态机图可以直观的表示词法分析的流程,以后扩展数值类型支持浮点数之类的,都可以从画状态机图开始。比如大部分计算机语言支持的数字类型,可以用下面的状态机图来表示(图片来自Online JSON Viewer):
除了状态机,另一个超级适于描述词法分析器的就是正则表达式,有兴趣的同学可以自行去了解下。著名的词法分析器Lex就是用正则表达式描述词法规则的。
以最典型的number
函数为例:
1 | int number(slm_expr *e) |
我们把之前从e->expStr
直接取值的代码都换成读取e->token
。还要把(e->expStr)++
的地方都替换为TRY(next(e))
,加上TRY
是因为next
里面也会报非法token的错误。当然不能忘记的是,在main
函数里必须预先调用一次next
,不然首次进入语法解析器的时候e->token
会是空的。
把所有语法分析步骤里的代码替换完之后,我们就可以得到一个能剔除空格、识别非法字符和多位数字的解析器啦。完整的代码我就不在这里全贴出来了,存放在SlimeExpressionC,欢迎大家自取。
(四)用词法解析处理多位数字和空白符
]]>本章还会顺带聊一聊负数的解析,用递归的方式处理负数可以做的很简单,想复杂点也可以做的很复杂。如果是用调度场算法处理表达式中的负数的话,推荐看看这一篇文章(英文),我就不深入分析了。负数解析不涉及到编译原理相关的新知识,不感兴趣也可以略过。
想要把正在解析的表达式,和解析中遇到的错误配对关联起来,在C语言里当然是用结构体最方便啦:
1 | typedef struct { |
然后我们要对现在的代码做修改,把所有传递const char **expStr
参数的地方改成传递slm_expr *e
,当然函数体里代码也要做对应的修改。
是不是有点熟悉?ObjC里的objc_msgSend就是这么玩的,python等部分语言里也是把self当做类成员函数的第一个参数。
做完了准备之后我们就要开始错误处理了,以number
函数为例,我们需要在出现不期望字符时报错:
1 | int number(slm_expr *e) |
可以看到我们报错的手段就是在结构体里把errType
标记成对应的错误,然后立刻终止解析。当然只终止当前函数的解析是不够的,上层函数发现下层函数解析出错了,应该递归的终止解析。我们以expr
函数为例:
1 | int expr(slm_expr *e) |
可以看到每次在调用term
函数后,我们都需要判断下它有没有设置过errType
,有的话就需要递归终止解析。当然大家会发现,对errType
的操作都是比较固定的模式,所以我们用个宏定义来让代码看上去简洁点:
1 |
用宏定义替换完代码后,我们的错误处理差不多就做完了,完整代码参照SlimeExpressionC-chapter3.1。不得不说没有提供try...catch...
语法的语言写错误处理是多么的蛋疼😂,如果是用高级语言那么这段逻辑会优雅很多。当然用goto
语句来实现错误处理也是可行的,但是一是难以阅读,二是容易玩脱,感兴趣的朋友可以自己试试。
负号的优先级是怎样的?我们来先看一个截图:
可见在常见的C语言编译器里面,负数出现在表达式中间是可以的,且负号优先级是比乘除法还高的。
关于C语言里运算符的优先级,大家可以参考这一篇文章:C运算符优先级
第三行炸了是因为后缀自减运算符优先级是最高的,所以--
被识别成了自减运算符。而自减运算符是不能应用在常量上的,所以出现了编译错误。其实像第四行一样用空格把两个减号断开一下,就又可以正常编译了。
我在解析器里就不打算支持自增自减运算符了,因为我个人十分讨厌人问我a---a
到底该解析成什么,所以从根源上杜绝这个问题。😜
为什么C语言要设计成这样呢?其实是因为这样的设计,对于文法和递归解析来说是最容易的。按照这样的设计,负号应该是数字解析中的一部分,所以我们把解析数字用的文法改进成这样:
1 | number -> '-' digit | digit |
这个逻辑十分简单,我们就不需要把它拆成两个函数来写了,事实证明写在一个函数里会更简洁些:
1 | int number(slm_expr *e) |
是的,支持负数只需要改这么一个函数,完整的代码参照:SlimeExpressionC-chapter3.2
上一小节提到的文法是解析负数的最简单文法,那么复杂点的场景要怎么处理?
我们举个例子:
1+-1
,1--1
这类写法总归不太符合正常习惯
-1+1
,-1-2
,1-(-1)
这类写法就正常些
总结起来就是,负号应该只出现在一个表达式(expr
)的首个数字里。如果想要实现这样的功能,我们的文法要怎么设计呢?那可麻烦了去了……
在递归逻辑里,如果想要记住一个状态,那么只能一步步的把状态传递下去,一种方式就是用文法进行传递,那么文法大概会设计成这么个样子:
1 | expr -> firstTerm {'+' term | '-' term} |
看我的表情……每一级向下传递都得多写一个产生式,我们现在的文法才这么简单就直接产生式数量double了,以后出现了函数解析、变量名解析之类的还不得原地爆炸?不敢想不敢想。
当然还有另一种方式,就是通过context传递状态。在面向对象的语言里那就是通过实例的属性/成员变量去传递状态,在我们的C代码里那就是给结构体再加一个布尔值变量isFisrtNumber
咯。
具体的做法就是在进入expr
函数时,把isFisrtNumber
置为true,在解析完第一个数字后,再把isFisrtNumber
置为false,只有在isFisrtNumber
为true的时候,解析数字才支持以负号开始。
等等,那万一以后我们支持变量了,i+-1
里的-1
的确是第一个数字啊,这时候咋办?改代码呗,第一个变量解析完之后也把isFisrtNumber
置为false。
等等,那万一以后我们支持函数了,f(1)+-1
里的-1
好像也有问题啊,咋办?再改……
等等,那expr
是会嵌套解析的,我们要不要搞一个堆栈记录每一层的isFisrtNumber
?……
总之,各种各样的问题会接踵而至,就是这样喵。所以呢,大家应该也明白了为什么我说上一小节提到的文法是解析负数的最简单文法。感兴趣的同学可以自己试试实现这种复杂的负号解析逻辑,我这里就不尝试实现了。今天的入门课也就到这里,希望可以拓宽一下大家的思路。
(三)简单错误处理逻辑以及负数的解析
]]>如果不是采用递归方式解析表达式的话,可以参考下调度场算法,这是一个利用队列和堆栈来解决计算优先级的经典算法。
用递归方式解析的话,只要深刻理解了上一章的知识,这一章的都是小意思,那么我们开始。
首先我们列出乘除法的文法:
1 | term -> term '*' num | term '/' num | num |
这里的term
是项的意思,是指在加减法里运算符左右的两个项,先不要纠结具体是什么意思,一会就会用到了。
我们以几个例子来人肉分析一下,这个文法和加减法的文法怎么结合,才能获得正确的优先级:
1 | expr(1+2*3) = num(1) '+' term(2*3) |
而在上面乘除法的文法里可以看到,实际上term
是可以推导为num
的,所以上述例子又可以变成:
1 | expr(1+2*3) = term(1) '+' term(2*3) |
是不是写到这里就豁然开朗了,如果乘除法的优先级更高,则让乘除法的解析先进行,乘除法的结果当做加减法的项就可以了。在文法里的表现就是,优先级越高的产生式会越靠后,结果是这样:
1 | expr -> expr '+' term | expr '-' term | term |
具体的验证大家自己试试就可以了,绝对可以按照正确的优先级进行解析。
文法已经准备好了,本来可以开始写递归逻辑了,但是我们得先解决前一章代码的隐患。先看看之前的代码:
1 | int expr(const char *expStr) |
看上去逻辑是没什么问题的,但是这是因为number
肯定是单个字符的,如果按照之前的代码继续实现本章的文法,就会得到这样的难题:
1 | int expr(const char *expStr) |
所以将字符串指针后移的操作,应该交给解析了字符或者说消化掉字符的角色来做,那么我们要用指针的指针来先改进一下上一章的代码:
1 | int number(const char **expStr) |
大家有了文法,也掌握了消除左递归的方法,还知道怎么转换成递归代码,我感觉其实都不太需要把代码给大家贴出来了。😂
乘除法的实现思路和上一章加减法的思路完全一致,我们顺带把取余也加上就好了:
1 | int term(const char **expStr) |
下一步就是把加减法里的number
解析都换成term
解析,换完之后的expr
函数会长这样:
1 | int expr(const char **expStr) |
实验下,确认我们的表达式解析器可以按照正确的优先级计算加减乘除了。
括号的运算优先级是比乘除法还要高的,所以我们新增一个非终结符factor
(因子),用来在文法中描述它:
1 | expr -> expr '+' term | expr '-' term | term |
嘿嘿,这次就真的不给大家贴代码了,相信这次大家应该可以熟练的搞定代码,毕竟已经是个成熟的程序猿了。
完整的代码存放在SlimeExpressionC,需要的同学可以自取,今天的入门课就到这里。
(二)递归解析中怎么处理运算符优先级
]]>首先为了能快速简单的开始写我们的解析器,先要对表达式的规则做一定简化:
然后我们会采用递归加循环的方式来解析表达式,还玩不转递归的同学必须要先过递归这道坎。
我们学习语法分析先得从文法入手,文法是解析表达式的关键,一个优秀的文法可以指导我们轻松的写出解析表达式的递归逻辑。这里的文法一般说的是上下文无关文法,后面就简称文法了。文法具体是什么,翻开书本或者打开wiki,看了半天定义可能还是一头雾水。我们来举个例子说明:
1 | sum -> num '+' num |
这是一个解析两数和的文法。文法包含4个元素:
终结符号:就是例子里的'+'
、'0'
、'1'
…'9'
,他们都是确定的字符。
一段可解析的表达式,最终总归能分解成这些终结符号且只能包含这些终结符号。什么意思呢?比如说1+2
在这个例子的文法里就是可解析的,+1+
仅包含终结符所以可能是可解析的,但是a+1
或者1?2
这种包含了别的符号所以肯定是不可解析的。
非终结符号:就是例子里的'sum'
和'num'
,他们都是由终结符或者非终结符组成的组合。
说起来他们就是描述文法时用的中间变量,最后在递归逻辑中的表现可能就是对应到一个递归函数上,要尽量使他们可复用。
产生式:例子的两行,每行就是一个产生式,他表达了一个非终结符是由什么组合成的,也就是说是用来描述符号之间关系的。
开始符号:文法需要指定一个非终结符号为开始符号,这个例子里面就是'sum'
。
开始符号的意义就是明确一下你最后要解析出来的是个什么。在这个例子里,最终想要解析出来的是一个求两数和的表达式,而不是一个数字。
好了,接着用这个例子演示下怎么用文法来进行推导,人肉推导一下1+2
:
1 | 过程: |
人脑推导这种简单的文法还是很简单的,但是电脑可做不到。为了让电脑可以按照文法解析表达式,我们要用到递归的办法,示例的伪代码如下:
1 | int num(表达式) { |
这样调用sum('1+2')
就可以正常的依次解析'1'
、'+'
、'2'
了,是不是很直观?用文法配合递归的方法,就可以很轻松的解析表达式。当然伪代码里还省略了很多细节,后面我们再补充。
首先列出来加减法表达式的文法:
1 | ① 正确示范 |
这里把正确示范和错误示范都列出来了,虽然两者都可以解析加减法表达式,但是②里的优先级是有问题的。具体是什么问题,我们用3-2+1
为例子分析一下:
1 | ① 正确优先级 |
虽然乍看两个文法都可以解析加减法表达式,但是仔细推导后就会发现只有文法①是正确的。
接下来就要试一试用递归来实现这个文法了,但是刚开始写就会发现悲催了:
1 | int expr(表达式) { |
这不就是一个死循环式的无限递归么!?
这里就要引入一个消除左递归的概念。
我们现在遇到的情况是一种直接左递归,也就是类似A->Aα
这种文法,这里用大写英文字母表示非终结符,小写希腊字母表示终结符,在一个产生式里的第一个元素就是产生式左侧的非终结符自身,这就叫做直接左递归。A->Aα
只是举个例子哈,但是它其实是个非法的文法,因为永远包含非终结符,所以永远停不下来😂。
最简单且合法的直接左递归文法应该是A->Aα|β
,我们人肉分析一下它的“特色”可以得出:A
可以匹配的表达式一定是一个β
后面跟着0到无限个α
。所以说起来这个文法可以转换成:
1 | A -> β {α} // {} 表示内部元素可以出现 0 - N 次 |
这样的话就可以用递归配合循环来写解析逻辑了,后面我们实际上就会用这种方式来写解析逻辑。
但是这种文法看起来不是很直观,括号嵌套多了看着眼花,所以我们还要讨论另一种变换形式的文法:
1 | A -> β B |
大家自己思考一下,应该就能发现这个文法和A->Aα|β
其实是一模一样的。拿一个βαα
解析下作个实验:
1 | A(βαα) = β B(αα) = β α B(α) = β α α B(null) = β α α |
把加减法表达式的文法消除左递归,得到:
1 | expr -> num expr1 |
然后我们就可以去写对应的逻辑代码了,前面也说过了实际写的时候我们会把expr
函数写成递归配合循环式的,也就是类似这样:
1 | int number(const char *expStr) |
真的没有骗你们,核心代码一共就这么点。因为我们限定了表达式的规则,所以每一个字符都是一个元素,每识别一个元素后对字符串指针做自增操作就可以移动到下一个元素继续进行分析,逻辑可以写得十分的简单。因为我们也不用纠结运算符优先级,所以每识别一个运算符,就可以直接进行计算得到进一步的结果。
这么短的代码应该难不倒各位吧?在理解了文法的原理后,就可以理解这短短十几行代码里的奥秘。完整的代码放在SlimeExpressionC,需要的同学可以自取,今天的入门课就到这里啦。
(一)用最简单的语法分析器解析加减法
]]>所谓编译器前端,主要是指词法分析、语法分析这一类解析的过程,负责把我们写的代码翻译成计算机可以理解的格式。编译器后端,主要负责把前端解析得到的中间代码进行优化,生成CPU可以运行的二进制代码。编译器后端的知识,需要对汇编、计算机组成原理之类的知识有一定了解,才能更好的理解。所以我在这里不太打算深入讲解编译器后端的知识,想要全面了解编译原理的同学可以参考别的教程进行学习。
曾经尝试学习过编译原理的同学,可能会深有感触,抱着书啃起来很枯燥,很容易从入门到放弃。编译原理的三大著名书籍人称龙书、虎书、鲸书,具体书名大家自己搜一下就很容易找到。我们比较熟悉的一本应该就是下图这个龙书——《编译原理》,普及最广应该是因为翻译得比较好吧。书里说的大部分是理论知识,很可能看完三四章后,了解了很多编译器中的概念和方法,但是想要自制个编译器就会觉得无从下手。不过这不会影响它的地位,想深入学习编译原理肯定还是离不开它的,建议对编译器感兴趣的同学先从博客入门,入门后如果觉得想要更深入,再买一本《编译原理》回去啃也不迟。
首推一个lotabout大佬的《手把手教你构建 C 语言编译器》系列博客。博客从构建虚拟机开始,然后逐步的介绍词法分析再到语法分析,围绕着已经构建好的虚拟机一步步构造编译器。从构造编译器的过程上来说,大概是下图这样:
lotabout大佬的教程总结起来有以下特点:
编译器完整:包含编译器前端和编译器后端,对了解编译器完整工作流程有很大帮助
功能丰富:支持变量,条件和循环语句等复杂功能
较为深入:对虚拟机设计的讲解,以及对应虚拟机的代码生成逻辑都讲得较为深入。这个有好有坏,好处当然是大家能学到的东西更多,坏处就是对于不了解CPU和汇编的同学来说太难理解
中期无法运行:教程中期几篇的结尾都会有“本章的代码还无法正常运行”。这是必然的,编译器必须完成完整的流程才能运行,在虚拟机的基础上没有完成生成代码的逻辑,肯定会无法运行。这就可能让中间的学习过程有一定的断层
推荐的开源库首先也是推荐lotabout大佬博客对应的GitHub开源库:write-a-C-interpreter。光对着代码干啃很累,有对应的博客当然还是学起来更快的。
然后推荐的是Fabrice Bellard大神的otcc,这应该是最迷你的C语言编译器了,迷你但五脏俱全,甚至于可以做到自举(自举就是自己可以编译自己)。它是当年Fabrice Bellard参加国际混淆(混乱)C语言代码大赛的获奖作品,可以编译C语言的子集。当然我们阅读代码的时候请阅读非混淆版本的,不然你的大脑可能得跟计算机一样才能看懂写得是什么……
想再深入学习代码就试着看TinyCC吧,是Fabrice Bellard基于otcc扩展写出来的完整的C语言编译器,号称最快最小。这个级别的代码,反正我已经看不懂了……
我这里要写的入门课,一开始就说了不包含编译器后端,所以这里不能叫它编译器,只能叫做解释器或者说计算器。和大部分的编译原理课不同,我会先写出来可以运行的最小单元,然后一边展开知识范围一边迭代,让解释器可以支持更多的功能。大概的过程会是这样:
是不是更像是一个软件的常规迭代过程些?入门课会有以下特征:
目录会如下:
后面还可能会补充其他内容,要看看大家对什么内容/功能感兴趣,还要等我的懒癌被治好。
入门课对应的代码都会开源放在GitHub:SlimeExpressionC
想要更多的功能/教学可以在我的博客里留言,或者到对应的开源库下面去提issue,也热烈欢迎你们提MR或者fork出去自己玩。今天就先水到这里。
]]>Compare version numbers in Objective-C
里面提到的 [versionStrA compare:versionStrB options:NSNumericSearch]
的方案应该是最优雅的方案了。
但是不理解这个 NSNumericSearch 的具体工作原理就去盲目使用是危险的,今天我就来研究下它的具体工作原理。
参照官方文档里的说明:
1 | Numbers within strings are compared using numeric value, that is, |
粗略的直译一下:
1 | 在字符串中的数字将被用数值进行比较,就是说,Name2.txt < Name7.txt < Name25.txt。 |
这段说明略有歧义,导致很多人第一次看的时候被绕晕。例如刚刚那篇 StackOverflow 问答里的 dooleyo 就理解成 "1.2.3"
和 "1.1.12"
进行比较时,会抛弃所有非数字的字符变成 123
和 1112
进行比较,最后得到 "1.2.3" < "1.1.12"
的结论。问答里不少其它朋友也有类似的想法。
真相只有经过实验才能得到,所以写了一些测试代码来试一下具体的结果:
1 | - (void)testExample { |
得到的结果是:
1 | 1.2.3 > 1.1.12 |
看上去结果都是正确的,那么看来 NSNumericSearch
并不是粗暴的去掉所有非数字字符后进行数值比对。
结合原回答里答主说的一句话:keeping in mind that "1" < "1.0" < "1.0.0"
,忽然想到了一些什么,继续进行下一步的实验,得到的结果如下:
1 | 1 < 1.0 |
大家看到这里应该可以猜到官方文档的意思是什么了,其实文档的意思是整个字串非数字的部分仍然进行常规的字符比较逻辑,只有在遇到数字的时候会把连续的数字转换成数值再进行比对。具体的比较过程示例参照下图:
这时候一些奇特的比对结果就可以解释明白了,比如使用这种比较模式会得出 "01" = "1"
。
那么还剩下一个问题,如果和数字字符比较的是非数字字符,会怎么样?我们可以挑一些 ASCII 码在数字字符周围的字符进行试验,结果如下:
1 | a/c < a100c |
注意这里出现的部分字符 ASCII 码为:
1 | / = 47 |
可以看出现了比较的字符一边是数字,一边是非数字时,是按照常规的 ASCII 码进行比对的。
那么分析到这里就基本结束了,剩下的一些场景类推一下都很容易理解。
其实在各大 OS 里的文件系统下文件排序用的就是这种比较方法,一开始没有想到这点所以理解上绕了一些弯路。
用 NSNumericSearch
来进行版本字符串的比对也是十分有效的,不是特殊需要的话就再也不用傻傻的自己分割字符串再分段比较啦。
作为热爱响应式的程序猿,一定是要试用评测一下这传说中又快又好用的新框架的,事不宜迟我们开始。(虽然这框架已经开源一个月了🙄)
评测的具体方案是用我以前的 MvvmDemo 改造一下,旧 demo 的代码参照 GitHub。使用这个改造的方案,可以更方便的进行 EasyReact 和 RAC 的对比。
首先进行 EasyReact 的安装,不得不说支持 CocoaPods 的库安装起来还是方便。但是 EasyReact 是没有提供打包好的 Framework 或者对应的 Framework 工程的,这就不太方便进行一次打包多处直接使用二进制包了。
EasyReact 优缺点 |
---|
✅ 支持 CocoaPods |
❌ 没有提供二进制 Framework |
为了方便对比,我把使用 EasyReact 和 RAC 的对比做成了一个独立的 commit 0feb1cb。可以看到其实从语法上来说,它们的常规使用方法十分的相似。然后我们来一点点比较细节的差异。
RACSignal 的设计概念是表示一个可以被订阅的信号流,最主要的意义是表示其内部的值是变化的。而 RACSubject 是表示一个热信号流,热信号和冷信号的内容后面再说,当前主要先要说的 RACSubject 的特征是可以手动发送信号。
EZRNode 从设计上看上去更像是一个存着 value 的 model,这个使得初学者很容易理解它的用途。而 EZRMutableNode 使得 node 存着的 value 可以被修改,然后修改这个 value 的时候就会对外发出信号。说起来我个人觉得这种设计的确可以让过程式编程的开发者更容易理解和过渡到响应式编程中,但是有点略二不像的设计也会带来对应的困扰。
如果我们认为 EZRNode 的 value 是不可变的,那么 EZRNode 提供 listenedBy:
就会很奇怪,一个不可变的值我们监听它干什么呢?
如果我们认为 EZRNode 的 value 是可变的,那么有些接口的设计又看上去很怪,典型的代表就是响应式编程最常用到的宏定义 EZR_PATH
的实现类 EZRPathTrampoline
,在其内部都默认认为 EZRMutableNode 才可以进行绑定。
我觉得从总体设计上来看,其实应该认为 EZRNode 的 value 值是可变的,EZRNode+Operation.h
中的变换都是基于 EZRNode 来实现的可以证明这一点。另一种理解是哪怕是不可变的值,其实也可以变换和监听的嘛,这样看起来 EZRNode 的意义和 RACSignal 其实是十分接近的。
另外 EZRPathTrampoline.m
里面有个小细节:
1 | - (void)setObject:(EZRNode *)node forKeyedSubscript:(NSString *)keyPath { |
可以看到在头文件里定义的 node
参数是 EZRMutableNode,但是类实现里其实用的是 EZRNode,让我不禁怀疑是不是头文件里的类型写错了……😓
得出的第一个结论:姑且认为 EZRNode 的意义和 RACSignal 相同,是信号的最基础单元。
这个问题和问题1其实有点重叠,主要原因的根源还是宏定义 EZR_PATH
的实现类 EZRPathTrampoline
。
对外暴露 EZRNode 类似于对外暴露一个 readonly 的属性,用户表面上可以感知到不可以修改其内部的 value。但是面临的一个问题是,用户想要使用 EZR_PATH
宏进行绑定时还是要进行一次 mutablify
的转换。
对外直接暴露 EZRMutableNode 的话相当于暴露了一个 readwrite 的属性,用户不仅可以监听它,同时也具备了可以修改其 value 的能力,这对于维持一个 ViewModel 的封装性来说可是个灾难。
还有一点是,EZRNode 转成 EZRMutableNode 时,复用了原先的内存地址。
1 | EZRNode *node = [EZRNode new]; |
上面代码里 node
和 mutableNode
的指针是完全相等的,当然它们的 class 也都会是 EZRMutableNode。这样的好处就是转换前后,它们的逻辑都是连续的;坏处是类型原地转换的逻辑会导致使用方比较混乱(可能前一秒还是 EZRNode 的实例,下一秒就被别人变成 EZRMutableNode 了),另外 mutablify
的转换也是不可逆的。
这样设计应该也是没有办法:虽然说起来它们和 NSString & NSMutableString 组合有很多相似的地方,但是要支持 copy 协议是很麻烦的。比如想要维持监听的链路不被打断,信号源这种东西在支持 copy 时是很容易出大问题的,要复制要维持的状态多得难以想象。
综上所诉,我们设计接口时到底是暴露 EZRNode 还是 EZRMutableNode 类型会有很大的困扰。相比较而言,RAC 就没有这个困扰,不想让别人知道这是个可以手动发信号的 RACSubject,包装成 RACSignal 暴露出去就好。其实我还是觉得 EasyReact 去修改下 EZRPathTrampoline
应该也可以达成类似的效果😓。
不过关于 Node 可变状态的转换的确也没有想到什么好的办法,现在的这个设计模式,即使用 readonly 式的 EZRNode 暴露接口给外界也是形同虚设,毕竟外界拿到这个 EZRNode 之后手动 mutablify
一下,然后想怎么改就怎么改。
冷信号一直是 RAC 里面一个让响应式编程新手懵逼的概念,详细的概念我在《RAC中的冷信号与热信号》中介绍过。
既然容易让新手懵逼,那么 EasyReact 是怎么处理的呢?EasyReact 里好像就压根没有提供冷信号的概念😂。
这样倒是也挺好的,让使用者自己基于 block 和各种事件倒是也能完成类似的逻辑,省得新手在理解上有错误而导致写出的代码有严重问题。
EasyReact 优缺点 |
---|
✅ 易理解,抛弃了大量对初学者很晦涩的响应式概念 |
❌ 框架内部接口的设计对 EZRNode & EZRMutableNode 的理解貌似本身就不一致 |
❌ 不可变和可变 node 的无缝转换过程可能引发其它业务方的逻辑混乱 |
❌ EZRNode 完全做不到 readonly 的效果,形同虚设 |
⚠️ 抛弃了冷信号的概念,这个优劣参半吧 |
EZR_PATH
宏是和 RAC 中的 RAC
& RACObserve
两个宏相同地位的核心宏方法,最大的不同点是它把 RAC 中的两个宏合并成了一个宏。
这是个好事儿还是坏事儿呢?我个人觉得两面都有。
1 | // RAC |
参照上面的代码示例还有 EZRPathTrampoline
的实现:
第三点我们拓展开来举个例子,用之前 MvvmDemo 里的代码来看,我想要知道哪些人监听过 ViewModel 的 username 属性,哪些人让 ViewModel 的 username 属性监听过其它信号:
快速定位,精准无误有木有!只用一个 EZR_PATH
宏的话这些就无法简单精准定位了,写复杂的正则或许能搞定,但是也会麻烦很多。这个需要自行体会,基础架构实现的底层模块的属性,被监听和监听其它属性的信号流多如牛毛,能让定位的复杂度降低是提高工作效率的重要保证。
EasyReact 优缺点 |
---|
⚠️ EZR_PATH 宏易用、二合一,但是也导致难以区分是实现监听者还是被监听者 |
基于刚刚提到的 commit 0feb1cb 的 MvvmDemo 是不完整的,一个很重要的原因就是 UITextField 这类 UI 控件,是不可以通过监听它的 text 属性就能简单实现响应式的。所以我们必须要一个新的 commit ad46b53,来把 UITextField 依然通过 delegate 的方式链接到 ViewModel 上,说起来就是还是抛弃不了过程式的开发方法。
这点我相信美团内部应该还是有对应的一些封装吧,日后或许也会渐渐开源出来。毕竟如果一套响应式框架如果没有办法很便捷的应用到业务层的 UI 上,实用性就会大打折扣。
相比较来说沉淀了多年的 RAC 强大的多,不光连 UI 控件的扩展封装很完备,还为了具体的场景需要实现了 RACCommand 和 RACChannel 等类,甚至于连 UserDefaults 都做了对应的扩展封装。
EasyReact 优缺点 |
---|
❌ 没有对系统类的扩展,易用性大打折扣 |
美团官博写着的 EasyReact 还有一个最大的亮点就是性能起飞!不过当然要实践出真知,不能盲目的相信当事人自己的数据。基于上面的 MvvmDemo,我来自己做一个简单的性能对比试验一下:
1 | - (void)testPerformance { |
如上单元测试,在 RAC 的分支和 EasyReact 的分支各实现一次,运行完了之后对比总耗时:
可以看到,在综合了 combine、listen、map 等操作的实验下,EasyReact 的效率在 RAC 的三倍以上。
EasyReact 优缺点 |
---|
✅✅ EasyReact 的效率在 RAC 的三倍以上 |
EasyReact 的 EZRNode 概念比起 RACSignal 来说,是确确实实的持有了一个 value 的,所以调试起来有相当大的优势。举几个例子:
setValue:
加个断点,设置进来的新值和之前的旧值都可以轻松获得。.value
拿到现在的节点值,按照文档描述这个值是线程安全的,放心使用。还有很多其它的可能性,不过这里先展开说一下第3点。下面是同一个单元测试,EasyReact 下的堆栈状态:
对应的 RAC 下的堆栈状态:
……
堆栈长度从22激增到了52(这也是 RAC 效率低一些的重要原因吧🙄)。
倒是如果把代码隐藏起来(非代码展开,直接使用打包好的 Framework),其实 RAC 的堆栈也会比较清晰:
可以看到虽然堆栈长度还是很大,但是层级上只展示了几个关键层。
这种信号流调试起来,说起来谁更方便些真的没有定论,因为毕竟都很麻烦😂。加上跨线程调用的情况,更是难上加难,所以我在这里也就不硬比个高低了。倒是总体说起来 EasyReact 概念简单,设计也简单,应该调试难度肯定会更低一些的。
EasyReact 优缺点 |
---|
✅ EasyReact 调试难度更低 |
文档也是重要的一点,这里长话短说了。
RAC 的文档一直是比较差的,这么难的框架还只能靠自己还有零散的博文来啃,的确有些吃力。看 RAC 各种复杂高级变换时,很多时候是借助 ReactiveX 框架的示意图(例如这个 zip 的示意图)来理解的,这些示意图很好很强大,学习响应式的朋友也可以去观摩学习下。
EasyReact 的设计比较简单,文档相对来说就好理解些,而且官方中文文档这点对于国人开发者来说太友好了!
社区的活跃度这点目前就不清楚会怎样了,国内的社区氛围一直比较差,还不清楚遇到具体的问题时,美团官方的跟进及各大社区的讨论会如何,只能说不抱很大期望。
EasyReact 优缺点 |
---|
✅ 文档齐全,官方中文 |
⚠️ 本人个人对社区氛围不抱太大期望 |
老实说光性能这一点,EasyReact 就值得推荐。对于学习响应式框架的初学者来说,EasyReact 是可以尝试的,整体来说它的概念更简单。但是就完备程度来说,EasyReact 还有一段很长的路要走,对 RAC 熟悉程度比较高的程序猿,开发效率肯定还是更高的。所以说从开发效率、运行性能和学习成本等各方面考虑,选择适合你们自己团队的响应式框架吧。
]]>1 | fruits: |
上面的 YAML 等同于 JSON:
1 | { |
是不是看上去简洁了很多,也更容易阅读很多?
YAML 的缩进只能用空格而不能用 tab,一个主要的原因是不同的系统对 tab 的处理不完全一致(比如有的系统把 tab 处理成 4 个空格,有的系统把 tab 处理成 8 个空格)。好在现代的文本编辑器基本都支持把 tab 转换成指定数量的空格。
YAML 里的元素都是用缩进来匹配层级关系的,简单说就是缩进相同的都是同级元素,缩进比上一个元素长就是上一个元素的子元素。
具体的例子如下:
1 | - apple1: |
对应的 JSON:
1 | [ |
请自行体会,我就不展开了。为了方便理解说一下前面的 YAML 等同于:
1 | - |
只要不是行首的缩进符,其它地方的词法分隔符是可以用各种 white space 字符的。但是要注意这是 YAML 1.2 的规则,在 YAML 1.1 里还是严禁用 tab 作分隔符的。我认为 YAML 1.2 做出这样的更改主要也是为了兼容 JSON。目前解析 YAML 的大部分库还是仅支持 YAML 1.1,所以为了兼容性,分隔符最好还是不要用 tab。
YAML 使用 “#” 来进行注释,”#” 及在其之后的当行内容将被忽略。注意 “#” 如果跟在别的元素后面,和元素之间需要用 white space 字符隔开。
说起来会絮絮叨叨的,懒得说了……请自行看官网文档。
这就是大部分语言里的基础类型,YAML 里常用的纯量有以下类型:
~
/ null
/ Null
/ NULL
还有空字符都被解析成 null 类型,最标准的写法是 ~
。
最新标准里 y
/ Y
/ yes
/ Yes
/ YES
/ n
/ N
/ no
/ No
/ NO
/ true
/ True
/ TRUE
/ false
/ False
/ FALSE
/ on
/ On
/ ON
/ off
/ Off
/ OFF
全部都会被解析成正确的 bool 类型,为了兼容性比较好建议用 true
和 false
。
举个例子,我使用的在线解析器解析如下 YAML:
1 | - on |
解析出的 JSON:
1 | [ |
很常规就不多介绍了,YAML 支持 8 进制和 16 进制格式的数据,甚至 2 进制和 60 进制。
支持常规的浮点数,支持科学计数法,还支持无穷大和 NaN。详情可以参考 tag:yaml.org,2002:float。
大部分情况下,YAML 里的字串是不需要带引号的,某些容易引起解析歧义的字串可以用引号括起来。
示例 YAML:
1 | - a b c |
对应 JSON:
1 | [ |
顺带说明下双引号会对转义符进行操作,而单引号不会。双引号内包含双引号可以用 \"
来表示,单引号内包含单引号可以用 ''
来表示。
其它纯量是很不常用的类型,可以自行查阅官方文档。
另外要提一点,YAML 支持类型强转:
1 | - 123 |
对应 JSON:
1 | [ |
字典:
1 | a: b |
对应 JSON:
1 | { |
数组:
1 | - a |
对应 JSON:
1 | [ |
字典的 value 为数组:
1 | a: |
对应 JSON:
1 | { |
数据的元素为字典:
1 | - |
对应 JSON:
1 | [ |
数组有一种类似 JSON 的写法,可以完成行内数组的功能,当然和 JSON 一样写成多行的也可以:
1 | [a, b] |
对应 JSON:
1 | [ |
字典也可以用类似 JSON 的方法写成行内的:
1 | {a: b} |
对应 JSON:
1 | { |
引用是 YAML 的一个很方便的高级语法,示例如下:
1 | name: &name Jason |
解析出的对应 JSON:
1 | { |
可以看到基本的规则就是用 &
声明一个引用,然后在其他地方用 *
进行展开,有点像 c 语言的指针操作。
引用的部分就是在 &
之后的整个子元素,上面例子里 &name
引用的是 Jason
,而 &info
引用的是:
1 | name: Sara |
在后面使用对应的名称展开后就得到了最终的 JSON 内容。
到此 YAML 的基本语法就介绍得差不多了,更多的内容可以参考以下内容来继续深入阅读:
YAML 官方网站:http://yaml.org/
阮一峰 - 《YAML 语言教程》:http://www.ruanyifeng.com/blog/2016/07/yaml.html
YAML 在线解析:http://yaml-online-parser.appspot.com/
YAML 合法性校验:http://www.yamllint.com/
]]>朋友,ObjC 的 BOOL 类型了解一下?
可能有人告诉你 BOOL 是 signed char
类型的。放在以前,这个答案是对的,但是放在现在就不完全对了。接下来我来给大家一点点解释其中的细节。
当前我的 Xcode 版本是 9.3.1,BOOL 的定义是这样的(有适当删减):
1 | #if TARGET_OS_OSX || (TARGET_OS_IOS && !__LP64__ && !__ARM_ARCH_7K) |
作为 iPhone 开发者(🙄),可以近似的理解为在 64-bit 设备上 BOOL 实际是 bool
类型,在 32-bit 设备上 BOOL 的实际类型是 signed char
。
那么 YES
/ NO
又分别是什么值呢?我们看一下具体的定义:
1 | #if __has_feature(objc_bool) |
这里要先看一下 __objc_yes
和 __objc_no
是什么值,我们在 LLVM 的文档中可以得到答案:
1 | The compiler implicitly converts __objc_yes and __objc_no to (BOOL)1 and (BOOL)0. The keywords are used to disambiguate BOOL and integer literals. |
__objc_yes
和 __objc_no
其实就是 (BOOL)1
和 (BOOL)0
,这么写的原因就是为了消除 BOOL 和整型数的歧义而已。
(BOOL)1
和 (BOOL)0
这个大家应该也都能很容易理解了,其实就是把 1 和 0 强转成了 BOOL 对应的实际类型。
所以综上所述为了类型的正确对应,在给 BOOL 类型设值时要用 YES
/ NO
。
最早的标准 C 语言里是没有 bool
类型的,在 2000 年的 C99 标准里,新增了 _Bool
保留字,并且在 stdbool.h
里定义了 true
和 false
。stdbool.h
的内容可以参照这里:
1 | #define bool _Bool |
这里只截取了标准 C 语言情况下的定义(C++ 是自带 bool 和 true、false 的)。可以看到这里只是定义了它们的值,但是却没有保证它们的类型,就是说 true
/ false
其实可以应用在各种数据类型上。
有些人还提到 TRUE
/ FALSE
这两个宏定义,它们其实不是某个标准定义里的内容,一般是早年没有标准定义时自定义出来替代 true
/ false
使用的,大部分情况下他们的定义和 true
/ false
一致。
我们可以写一段代码来验证下:
1 | BOOL a = TRUE; |
使用 Xcode 的菜单进行预处理,展开宏定义:
然后我们就可以得到展开后的结果:
1 | BOOL a = 1; |
可以看到 ObjC 是自己定义了 BOOL 的类型,然后定义了对应要使用的值 YES
/ NO
,理所当然的第一个原因是我们要按照标准来。
另一方面,既然 ObjC 的 BOOL 使用的不是标准 C 的定义,那么以后这个定义可能还会修改。虽然说概率很低,但是毕竟从上面的代码看就经历了 signed char
到 bool
的一次修改不是么?为了避免这种风险,建议还是要使用 YES
/ NO
。
在某些情况下,类型不匹配会导致 warning,而 YES
/ NO
是带类型的,可以保证类型正确,所以建议要用 YES
/ NO
。
因为 BOOL 类型在不同设备有不同的表现,所以有一些地方我们要注意。
在 BOOL 为 bool
类型的时候,只有真假两个值,其实是可以写 “== YES” 和 “!= YES” 的。我们先举个例子:
1 | BOOL a = 2; |
在 64-bit 设备我们将得到结果:
1 | a is YES |
看上去没什么毛病,完美!
但是在 32-bit 设备我们将得到结果:
1 | a is YES |
这是为什么呢?因为在 32-bit 设备上 BOOL 是 signed char
类型的。ObjC 对数值类型做 (a)
这种真假判断时为 0 则假、非 0 则真,所以我们可以得到 a is YES
这种结果。但是对数值类型做 (a == YES)
这种判断时逻辑是什么样的,想必不用我说大家也猜到了,代码翻译出来就是类似这样的:
1 | signed char a = 2; |
我们当然只能得到 a != YES
这样的结果。
同样在 64-bit 的设备上,也就是 bool
类型上不会有这个问题,但是在 signed char
类型上就会有这个问题。我们先看代码:
1 | int a = 256; |
在 32-bit 设备上输出结果:
1 | a is YES |
是不是有点魔幻?但是原因也惊人的简单:
a
的二进制值为 00000000 00000000 00000001 00000000signed char
类型的 b
时丢失了高位b
的二进制值为 00000000所以千万不要做这样的蠢事,更常见的例子是一个 C 函数(十分直观):
1 | // 正确的用法 |
希望今天的介绍可以让你更深入的了解 ObjC 的 BOOL 类型,小心点不要在代码里埋出大 bug 哦。😁
]]>我们在 JSON <-> Dictionary <-> Model 中面临的一个很大的问题就是判断数据需要转换成什么样的类型。好在 ObjC 作为一款动态语言,利用 runtime 可以轻松解决这个问题。再配合转换器和 KVC,就可以轻松把我们解析好的值放进对应 Model 里。今天要给大家介绍的就是这个类型编码(Type Encodings)的具体细节。
编码 | 意义 |
---|---|
c | char 类型 |
i | int 类型 |
s | short 类型 |
l | long 类型,仅用在 32-bit 设备上 |
q | long long 类型 |
C | unsigned char 类型 |
I | unsigned int 类型 |
S | unsigned short 类型 |
L | unsigned long 类型 |
Q | unsigned long long 类型 |
f | float 类型 |
d | double 类型,long double 不被 ObjC 支持,所以也是指向此编码 |
B | bool 或 _Bool 类型 |
v | void 类型 |
* | C 字串(char *)类型 |
@ | 对象(id)类型 |
# | Class 类型 |
: | SEL 类型 |
[array type] | C 数组类型(注意这不是 NSArray) |
{name=type…} | 结构体类型 |
(name=type…) | 联合体类型 |
bnum | 位段(bit field)类型用 b 表示,num 表示字节数,这个类型很少用 |
^type | 一个指向 type 类型的指针类型 |
? | 未知类型 |
简单给大家举个例子,我们先来看看常用的数值类型,用下面的代码来打印日志:
1 | NSLog(@"char : %s, %lu", @encode(char), sizeof(char)); |
在 32-bit 设备上输出日志如下:
1 | char : c, 1 |
大家注意下上面日志里的 long
类型输出结果,然后我们再看下在 64-bit 设备上的输出日志:
1 | char : c, 1 |
可以看到 long
的长度变成了 8,而且类型编码也变成 q
,这就是表格里那段话的意思。
所以呢,一般如果想要整形的长度固定且长度能被一眼看出,建议使用例子最后的 int32_t
和 int64_t
,尽量少去使用 long
类型。
然后要提一下 NSInteger
和 CGFloat
,这俩都是针对不同 CPU 分开定义的:
1 | #if __LP64__ || (TARGET_OS_EMBEDDED && !TARGET_OS_IPHONE) || TARGET_OS_WIN32 || NS_BUILD_32_LIKE_64 |
所以他们在 32-bit 设备上长度为 4,在 64-bit 设备上长度为 8,对应类型编码也会有变化。
用下面的代码打印日志:
1 | NSLog(@"bool : %s, %lu", @encode(bool), sizeof(bool)); |
在 32-bit 设备上输出日志如下:
1 | bool : B, 1 |
在 64-bit 设备上输出日志如下:
1 | bool : B, 1 |
可以看到我们最常用的 BOOL
类型还真的是有点妖,这个妖一句两句还说不清楚,我在下一篇博客里会介绍一下。在本篇博客里,这个变化倒是对我们解析模型不会产生很大的影响,所以先略过。
用下面的代码打印日志:
1 | NSLog(@"void : %s, %lu", @encode(void), sizeof(void)); |
在 64-bit 设备上输出日志如下:
1 | void : v, 1 |
在 32-bit 设备上指针类型的长度会变成 4,这个就不多介绍了。
可以看到只有 C 字串类型比较特殊,会处理成 *
编码,其它整形数据的指针类型还是正常处理的。
用下面的代码打印日志:
1 | NSLog(@"CGSize: %s, %lu", @encode(CGSize), sizeof(CGSize)); |
在 64-bit 设备上输出日志如下:
1 | CGSize: {CGSize=dd}, 16 |
因为 CGSize
内部的字段都是 CGFloat
的,在 64-bit 设备上实际是 double
类型,所以等于号后面是两个 d
编码,总长度是 16。
联合体的编码格式十分类似,不多赘述。而位段现在用到的十分少,也不介绍了,有兴趣了解位段的可以参考维基百科。
ObjC 数据类型大部分情况下要配合 runtime 使用,单独用 @encode
操作符的话,基本上也就能做到下面这些:
1 | NSLog(@"Class : %s", @encode(Class)); |
输出日志:
1 | Class : # |
可以看到对象的类名称的编码方式跟结构体相似,等于号后面那个 #
就是 isa
指针了,是一个 Class
类型的数据。
我们可以用 runtime 去获得类的属性对应的 type encoding:
1 | objc_property_t property = class_getProperty([NSObject class], "description"); |
我们会获得这么一段输出:
1 | description - T@"NSString",R,C |
这里的 R
表示 readonly
,C
表示 copy
,这都是属性的修饰词,不过在本篇先不多介绍。
主要要说的是这里的 T
,也就是 type
,后面跟的这段 @"NSString"
就是 type encoding 了。可以看到 runtime 比较贴心的用双引号的方式告诉了我们这个对象的实际类型是什么。
关于属性的修饰词,更多内容可以参考 Apple 文档。其中 T
段始终会是第一个 attribute,所以处理起来会简单点。
而如果是成员变量的话,我们可以用类似下面的办法去获得 type encoding:
1 | @interface TestObject : NSObject { |
获得的输出会是这样:
1 | testInt - i |
因为成员变量没有属性修饰词那些,所以直接获得的就是 type encoding,格式和属性的 T
attribute 一样。
有的时候模型设置数据的方式并不是用属性的方式,而是用方法的方式。我们举个例子:
1 | Method method = class_getInstanceMethod([UIView class], @selector(setFrame:)); |
可以获得输出:
1 | setFrame: - v48@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16 |
输出就是整个类方法的 type encoding,关于这个我没找到官方文档的介绍,所以只能根据自己的推测来介绍这个编码的格式:
v
是表示函数的返回值是 void 类型48
表示函数参数表的长度(指返回值之后的所有参数,虽然返回值在 runtime 里也算是个参数)@
表示一个对象,在 ObjC 里这里传递的是 self
,实例方法是要传递实例对象给函数的0
上面参数对应的 offset:
表示一个 selector,用来指出要调用的函数是哪个8
是 selector 参数的 offset,因为这是跑在 64-bit 设备上的,所以 @
和 :
的长度都是 8{CGRect={CGPoint=dd}{CGSize=dd}}
是 CGRect 结构体的 type encoding,从这里也可以看出结构体嵌套使用时对应的 type encoding 是这种格式的,这个结构体包含 4 个 double 类型的数据,所以总长度应该是 3216
是最后一个参数的 offset,加上刚刚的参数长度 32 正好是整个函数参数表的长度我们拿另一个类方法来验证下:
1 | Method method = class_getInstanceMethod([UIViewController class], @selector(title)); |
输出:
1 | @16@0:8 |
可以看到很可惜,NSString 类型在类方法的 type encoding 里是不会有引号内容的,所以我们只能知道这个参数是个 id 类型。编码的具体解析:
@
- 返回 id 类型16
- 参数表总长度@
- 用来传递 self
,是 id 类型0
- self
参数的 offset:
- 传递具体要调用哪个方法,selector 类型8
- selector 参数的 offset如果是类的静态方法而不是实例方法,我们可以用类似这样的代码获得 Method 结构体:
1 | Method method = class_getClassMethod([TestObject class], @selector(testMethod)); |
不过说起来这种格式的编码还是不容易解析,所以我们可以用另一种方式直接拿对应位置的参数的编码:
1 | Method method = class_getInstanceMethod([UIView class], @selector(setFrame:)); |
输出内容如下,这里是获得了 index 为 2 的参数的编码:
1 | setFrame: - 3 |
这样就只会获得 type encoding 而不会带上 offset 信息,就容易解析多了。
另外从这里也可以看到,返回值其实也是算一个参数。
还有些 type encodings 的细节和解析模型其实不太相关,不过也在这里介绍一下。
用以下代码打印日志:
1 | objc_property_t property = class_getProperty([UIScrollView class], "delegate"); |
会获得输出:
1 | delegate - T@"<UIScrollViewDelegate>",W,N,V_delegate |
可以看到在属性的 type encoding 里,会用双引号和尖括号表示出 protocol 的类型
但是去查看方法的话:
1 | Method method = class_getInstanceMethod([UIScrollView class], @selector(setDelegate:)); |
依然还是只能得到这样的编码:
1 | setDelegate: - 3 |
protocol 类型在模型解析中并没有很大的指导作用,因为我们无法知道具体实现了 protocol 协议的 class 是什么。
直接亮结果吧,获得的 type encoding 是 @?
,没有任何参考意义,还好我们做模型解析用不到这个。
对 setEnable:
方法取 type encoding 的话会得到:
1 | setEnabled: - v20@0:8B16 |
可是 bool 的长度明明只有 1 啊,所以这是为什么呢?感兴趣的朋友可以了解下内存对齐。
关于 Type Encodings,要讲的差不多就这么多了。暂时没有想到还有什么要补充的,后面想到了再补上来吧。
希望对大家有帮助,也欢迎大家指正错误或者进行讨论。
]]>先贴出一张 iOS 中 UILabel 的默认排版样式:
大家也都能看出来,默认的排版样式中,文本的行间距很小,显得文本十分挤。
这种时候,设计师就会提出行间距的需求,希望让文本展示得更美观。类似的标注就会像这样:
对于如此合理的要求,我们当然是要支持,特别是设计师还是漂亮小姐姐的情况下。通常来说既然设计师要求的是行间距,那么我们直接设置 lineSpacing 就好。但是 UILabel 是没有这么一个直接暴露的属性的,想要修改 lineSpacing,我们需要借助 NSAttributedString 来实现,示意代码:
1 | NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; |
运行一下观察效果:
虽然用我们的眼睛看上去好像没什么问题,但是设计师的火眼金睛一下就能看出来,和设计稿要求的有差距:
怎么会成这样!?这跟说好的不一样对不对!?不要慌,我来细细解释下。
先看示意图:
红色区域是默认绘制单行文本会占用的区域,可以看到文字的上下是有一些留白的(蓝色和红色重叠的部分)。设计师是想要蓝色区域高度为 10pt,而我们直接设置 lineSpacing 会将两行红色区域中间的绿色区域高度设置为 10pt,这就是问题的根源了。
那么这个红色的区域高度是多少呢?答案是 label.font.lineHeight
,它是使用指定字体绘制单行文本的原始行高。
知道了原因后问题就好解决了,我们需要在设置 lineSpacing 时,减去这个系统的自带边距:
1 | NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; |
观察一下效果,完美契合(可以在小姐姐面前吹一年):
如果你只关心 iOS 设备上的文本展示效果,那么看到这里就已经够了。但是我需要的是 iOS 和 Android 展现出一模一样的效果,所以光有行间距是不能满足需求的。主要的原因在前言也提到了,Android 设备上的文字上下默认留白(上一节图中蓝色和红色重叠的部分)和 iOS 设备上的是不一致的:
左侧是 iOS 设备,右侧 Android 设备,可以看到同样是显示 20 号的字体,安卓的行高会偏高一些。在不同的 Android 设备上使用的字体不一样,可能还会出现更多的差别。如果不想办法抹平这差别,就不能真正意义上实现双端一致了。
这时候我们可以通过设置 lineHeight 来使得每一行文本的高度一致,lineHeight 设置为 30pt 的情况下,一行文本高度一定是 30pt,两行文本高度一定是 60pt。虽然文字的渲染上会有细微的差别,但是布局上的差别将被完全的抹除。lineHeight 同样可以借助 NSAttributedString 来实现,示意代码:
1 | NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; |
运行一下观察效果:
在 debug 模式下确认了下文本的高度的确正确的,但是为什么文字都显示在了行底呢?
修正文字在行中展示的位置,我们可以用 baselineOffset 属性来搞定。这个属性十分有用,在实现上标下标之类的需求时也经常用到它。经过调试,发现最合适的值是 (lineHeight - label.font.lineHeight) / 4
(尚未搞清楚为什么是除以 4 而不是除以 2,希望知道的老司机指点一二)。最终的代码示例如下:
1 | NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; |
贴一下在不同字号和行高下的展示效果:
不得不说行高和行间距我们都已经可以完美的实现了,但是我在尝试同时使用它们时,发现了 iOS 的一个 bug(当然也可能是一个 feature,毕竟不 crash 都不一定是 bug):
着色的区域都是文本的绘制区域,其中看上去是橙色的区域是 lineSpacing,绿色的区域是 lineHeight。但是为什么单行的文本系统也要展示一个 lineSpacing 啊!?坑爹呢这是!?
好在我们通常是行高和行间距针对不同的需求分别独立使用的,它们在分开使用时不会触发这个问题。所以在 VirtualView-iOS 库中,我暂且将高度计算的逻辑保持和系统一致了。
至此,成功的为 VirtualView-iOS 添加了对 lineHeight 属性的支持,更多的实现细节大家可以到开源库中直接看源代码。希望我们的 Tangram 方案可以更加完善,帮助更多的人一次开发两端同时使用,用一块七巧板拼出大千世界。
本文首发于苹果核博客,也是我写的不需要授权😁,但是这个博客名字真不是我起的🙄。
]]>谨以此2018的第一篇博文和这崭新的博客,迎接新的开始。
]]>本文主旨是浅显易懂的讲解下冷热信号的区别和常见的使用误区,所以篇幅所限不会介绍些内部细节。
如果想要了解的更深入,可以参照William Zang的博文:细说ReactiveCocoa的冷信号与热信号。
文中部分内容参考RAC 4.x的文档:设计指南、框架概览,但是文章本身是介绍ObjC的RAC 2.5。
另外和前一篇博文一样,我称呼一组信号叫做信号流,单次发送的信号值为信号。
热信号流在RAC 3.x以后为Signal,在RAC 2.5中对应RACSubject。
所谓热信号流是用来观察一组随时间流逝的事件用的。一般来说它被用作观察执行中(in progress)事件的进度或流程,例如下载事件的进度和流程。
热信号流有以下特征:
举个例子,蒸包子大师傅一天的工作就可以成为一个热信号流,而围观大师傅蒸包子的人就是观察者:
可以说,这里的围观者更关注的是大师傅什么时候出笼包子这类事件。
注意我们先在RACSubject.m里加上一段NSLog来记录发出的事件:
1 | - (void)sendNext:(id)value { |
然后写代码模仿上面的例子:
1 | NSLog(@"create RACSubject"); |
观察日志:
1 | 2017-09-21 15:26:52.657 MvvmDemo[1246:146676] create RACSubject |
可以看到这完美的诠释了以上热信号流的各种特征。
冷信号流在RAC 3.x以后叫做Signal Producers,在RAC 2.5中对应RACSignal。
注意RACSubject是RACSignal的一个子类,这也是RAC中大家常搞不清冷热信号流的一个重要原因。😂
所谓冷信号流是创建一组信号和处理副作用的。它的主要作用是处理一组完整的操作或任务,供用户观察事件从开始到结束的整个流程和事件的结果。例如处理一整段网络请求相关操作,供用户观察请求的结果是什么。
冷信号流有以下特征:
举个例子,包子店收银员卖出包子的过程就可以作为一个冷信号流,而每个买包子的顾客就是一个订阅者:
1 | NSLog(@"create RACSignal"); |
注意,这里我们用count
模拟了一个剩余包子数量的外部值作为副作用。但是这里没有做多线程保护,各位自己写代码的时候一定要记得做好对应的多线程保护。
另外要加个NSLog,RACSignal发送信号其实最终是在RACPassthroughSubscriber.m里做的:
1 | - (void)sendNext:(id)value { |
观察日志:
1 | 2017-09-21 17:09:06.349 MvvmDemo[1494:203071] create RACSignal |
可以看到一开始创建RACSignal的时候没有触发买包子流程,每有一个新的顾客来就会触发一个新的流程,流程也可能走向不同的结果。
冷信号流是用于一对一环境的,为每个订阅者都重新执行一次独立的信号流。因此,冷信号流可能无意中被订阅很多次,重复运行很多次。看下面的代码:
1 | RACSignal *signal = [RACSignal createSignal:^RACDisposable * _Nullable(id<RACSubscriber> _Nonnull subscriber) { |
这例子里的RACSignal的flatten
和map:
等方法其实也是通过订阅原信号流进行转换后再输出的,所以这里是会打印出两次『触发一次信号流』的。这种情况的增多,就更容易导致冷信号流被订阅次数增加。
在这种前提下,我们就要注意以下情况下不能滥用冷信号流:
这时候我们可以用热信号流替代冷信号流,或者将冷信号流通过publish
或者multicast:
转换成RACMulticastConnection后进行热信号流式的一对多转发。这细节就不在此展开了,只给个示例:
1 | RACMulticastConnection *connection = [[RACSignal createSignal:^RACDisposable * _Nullable(id<RACSubscriber> _Nonnull subscriber) { |
热信号流的问题就在于后订阅的订阅者可能丢失之前的某些信号,如果确实需要之前的某些信号时,可以使用RACBehaviorSubject或RACReplaySubject来完成。当然要注意的是,使用这两个类的时候,就只能关注信号的值而不能关心信号发生的时间了,因为时间在replay过程中已经被破坏了。
总结起来,热信号流就像直播视频,冷信号流就像点播视频。正确的使用它们,是用好RAC的基础。
大家加油,有问题的欢迎共同讨论哦。
]]>