再谈工程师成长——从代码评审到新人培养

作为初入软件开发的新同学,你是否经常觉得对比老司机们,自己的代码质量一般,但又不知道如何快速提升? 本文将基于最近在团队内施行的代码质量提升培训计划,从代码评审出发,展开对新手工程师培养的讨论,借此对该计划进行总结,聊聊关于新人培养背后的思考。 先用一张图来看看本次培训计划的效果总结,然后我们进入正题。 目录 纯属虚构的对话 蓄谋已久的计划 考虑再三的设计 细节满满的落地 感触颇深的结果 写在最后的唠叨 纯属虚构的对话 本故事纯属虚构,如何雷同,纯属巧合 小F是一名有经验的程序员,单兵作战能力极强,独挡一面。最近Leader给他安排了新任务,担任一名新同学的mentor,于是他的角色发生了一些转变。但因为经验不足,他在带新人的过程中遇到了一些困惑。新人的代码质量不高,负责的模块质量越来越差,已经到了很难维护的地步。两人都很着急,小F尝试过加强代码review,但效果并不好,逐渐形同虚设。 老Y最近在团队内部,花了一个季度的时间,针对新同学进行了代码质量的培训计划,收到不错的反馈。小F看到了方案后找到老Y,想了解关于代码质量提升方面的经验,于是两人有了以下对话。 蓄谋已久的计划 小F:老Y你好,最近我开始带新同学,遇到一个问题,我发现新同学的代码质量不高,设计上缺乏扩展性的考虑,维护性差,有时候会造成需求延期或者线上问题。我们也采取了一些措施,比如代码review,但每次代码review时我都没有时间细看,导致维护的模块代码质量越来越差,陷入恶性循环中,不知道怎么解决比较好。最近看到你们在团队中开展的小灶班,效果挺好的,所以想和你详细了解下,希望可以解决我现在遇到的问题。 老Y:嗯,这个问题的确比较头疼,我们团队有大量新同学,这个问题非常突出。 小F:能先大致介绍一下小灶班吗?做了一件什么事情? 老Y:本质上我们做的事很简单,就是导师与新人每周进行1on1,但与常规1on1有所不同的是,我们配套增加了很多的机制,用于保障这个1on1的有效性,并为它设置了一个季度的时间限制,整体目标是围绕着如何让新同学在一个季度内,快速提升自己的代码质量。这些机制包含比如理论的学习,每周1on1要聊什么,季度结束之后如何评估成果,同时增加了月会,用于保障培训计划不跑偏。 小F:这个名字很有意思,为什么叫「小灶班」? 老Y:这个培训计划的流程较重,可能会给大家带来压力,所以想取个轻松的名字中和一下。整个培训计划占用时间最多的环节是每周新人与导师的1on1,很像我们读书时老师为个别学生开小灶一样,所以就取了「小灶班」。 小F:原来如此,的确很形象。听说培训计划的文档创建时间很早,有同学开玩笑说是蓄谋已久的? 老Y:哈哈,是的,刚启动时,有同学看到文档的创建时间是2022年1月,所以提出了这个问题。当时团队有很多新同学,所以我从那时就开始思考如何提升团队的代码质量,但一直没有令自己满意的方案,直到今年年初才大概有了思路。 小F:2022年创建的文档,直到2024年才落地,的确是“蓄谋已久”。为什么会经历这么长时间?小灶班的流程还挺复杂的,应该不是一次形成的吧,经历了什么样的过程? 老Y:的确比较波折。最开始打算推的是代码质量规范,当时定的很细,包含需求开发的完整周期,比如Swift语言的规范,需求的每个阶段具体应该做些什么等。这些规范非常正确,但是它有个很大的问题——很难落地。就和我们平常看到的各种SOP一样,除了刚开始以及遇到线上问题时,其它时间很少会用到。所以当时把推进规范这件事将交给团队负责架构的同学时,虽然他满口答应,但也直言说这个肯定没用。于是,这个规范便搁置了。 后面也有团队尝试进行线下代码review的方案,但依然有很多问题导致难以落地,比如线下review很难凑足人,review耗时过长,效果并不一定好,很多线下参加会议的同学,由于不了解需求的背景,能够指出的问题可能也仅限于语法与风格,而这些由Lint类的工具便可以检测出来。所以据我了解也没有很好的运行。 去年12月,一次我与团队同学1on1,聊到一名新同学代码质量的问题时,他提出了要给该同学制定一个学习计划,于是,我们顺着这个思路,展开讨论了新人培训的话题,包含学习什么内容、过程如何保障、如何进行考核等,这便有了小灶班的雏形。 再后来我基于这次讨论的内容,结合新同学自己在成长过程中遇到的一些问题,首先设立了几个原则,基于它们,才有了完整的小灶班培训计划。 小F:具体是什么原则? 老Y:基于那次讨论的内容,我设定了五个原则:可落地、针对重点人群、重流程但不过分占用时间、可衡量、日常化。 首先是可落地,之前很多代码review的策略,不管线上还是线下,都有一个问题——完全依赖大家自觉,约束力不强,同时目标并不明确,所以很难长久的推行下去,所以本次计划的首要原则就是所有的策略都可落地。 既然要可落地,那么必然需要流程来保障,因此,我设计了几个环节,每个环节都有相应的机制能够保障可落地这一要求。但毕竟平常的业务压力极大,不能够过分的占用时间,所以需要在重流程与时间占用之间取得平衡。 第三,由于流程较重,我希望做这件事的ROI最大化,因为团队同学的经验差异较大,不能针对团队所有成员进行培训,因此,我挑选了最迫切需要培训的人群,即毕业2-3年内的新人们。 第四,由于这个计划投入人力较多,并且有可落地的要求,因此在最后需要能够衡量计划提升的效果。 最后,培训只是短暂的,也并非所有同学都会参与小灶班,因此,我们希望代码质量的意识能够融入到日常开发过程中,这便是最后一个原则日常化。 正是基于这五个原则,我们设计了整套的小灶班培训计划。 小F:原来如此,由于有了这五个原则,它便与平常的代码review规范有很大的不同了。 老Y:是的,原有的代码review约束力较弱,尤其是封版压力大的情况下,基本形同虚设。在和很多新同学1on1的时候,大家反馈非常希望有人能仔细review自己的代码,但现在只能靠自己,参考原有的代码,靠自己的悟性,成长的速度非常慢。 考虑再三的设计 小F:有了这五个原则之后,你是如何设计整个小灶班机制的? 老Y:像前面所说,其实也挺简单。可以通过一张图来描述整个流程。首先选择要参加本次小灶班的同学及对应的mentor进行组队,每个小组制定为期三个月的学习计划,也就是理论的学习。在接下来的三个月中,小组每周进行1on1深度沟通,即所谓的每周小灶,新人与老司机就理论学习、需求、代码质量等话题进行深入的讨论。这也是区别于平常1on1不同的地方,平常更加随意,并不就某些具体的事情展开讨论。但每周小灶要求讨论的内容更明确,都是围绕着代码质量展开。除此以外,为了阶段性的了解进展,我们会分别组织新人及老司机的月会,讨论过去一个月中遇到的问题,优化流程。每月会由老司机准备一次关于代码设计的分享,进一步加强大家的理解。三个月周期结束之后,来到最终的考核环节,我们选择了源码剖析的方式,由新人结合过去的理论学习与实践,选择一个常用的开源库进行分析,并输出文章。当然,为了保证大家平常持续对代码质量的关注,我们还建立了叫「充满洁癖的代码」的话题群,用于日常讨论关于代码质量的一切。这便是小灶班的完整机制。 小F:的确是加了很多的机制来保障培训的效果。里面有很多细节我挺好奇的,比如为什么选择三个月为一个周期? 老Y:从我们的流程中可以看到,这是个比较耗费资源的培训,虽说占用时间并不过分,但在业务迭代压力如此大的情况下,一点点时间的占用都会对大家造成影响,所以这决定了培训不宜拉得过长。同时从效果上来说,很多同学停留在能完成需求即可,没有代码质量意识,不知道什么样的代码质量是好的,而在三个月的周期中,由师傅领进门之后,后续的修行还得靠个人。另外,因为最初设立了理论学习目标,以及每周仅有一次1on1,时间也不能太短,否则达不到想要的效果,因此我们将周期定为一个季度。 小F:整个流程中,老司机起到了非常关键的作用,他们的选择有什么考虑吗? 老Y:的确,所以老司机的选择我们很慎重,不仅需要他认真负责,平常对自己的代码质量也需要有很高的要求,只有这样才能给新人树立好榜样。同时,因为要涉及到具体的业务代码review,所以尽量选择同一个方向的资深同学,可以减少额外的业务学习成本。 小F:我注意到在培训计划的最开始需要制定学习目标,这是什么原因?直接review代码不就够了吗,为什么还要理论学习? 老Y:这是个好问题,在最初是没有这一环节的,主要想依赖每周的小灶班。但通过与很多新同学的沟通发现,大家对于代码设计的理论掌握的并不好,比如学校期间可能仅仅学过《设计模式》,但从来没有运用过,工作之后基本已经忘掉。面向对象也仅停留在考试中,很少在项目中使用。这三个月的时间,绝大部分是实践,实践涉及到的面是很窄的,所以如果没有理论的支撑,效果会大打折扣,所以后来增加了理论学习,要求新人与mentor一起制定一份三个月的理论学习计划。 小F:对学习计划有什么要求吗? 老Y:这里根据个人的经验,以及从一些老司机的建议中,我们将目标定为完成7大设计原则及23个设计模式的学习。并且推荐了几本经典著作,新人需要在三个月的时间里,将这些内容学习完成,每周与mentor讨论学习进展及心得,至于详细的安排,就不做要求了。 小F:为什么要mentor与新人都要参加对方的需求评审? 老Y:这里有两个考虑。首先mentor要参加新人的需求评审,因为如果mentor想要高质量的review新人代码,分析实现的问题,那么就要求mentor对需求足够了解,否则与现在的review没什么区别,最后只能发现语法或者规范上的问题。 另外新人也需要参加mentor的需求评审,目的是希望新人可以观察mentor如何做需求,在遇到问题时是如何思考,如何应对。过去和几个新同学的1on1中,我都会问一个问题,便是他们观察到哪些同学做的比较好的地方。很多同学反馈都是老司机们如何推进、沟通、设计代码等,给了他们很大的启发。 小F:的确,在实际工作中观察他人是如何做的,是非常高效的学习方式。 老Y:没错,所以我们希望新人可以从需求最初便开始关注,去观察mentor如何与PM讨论需求,如何提出建议,如何做技术评审,如何推进需求等等,后面的所有流程都依赖于一个前提,即对mentor的需求有足够的了解。 小F:从反馈结果来看,每周小灶是大家反馈收获最大的环节,具体需要讨论哪些内容?不让讨论流于表面,如何保障有效性? 老Y:是的,这也是我们投入最大的环节,每周新人与mentor需要坐下来聊一个小时,所以我对这个环节有非常大的期待。其实并没有限制大家的发挥,主要讨论预计包含三部分,理论学习的进展、需求代码、代码质量话题群的话题讨论。因为理论学习的压力很大,大部分同学都需要从晚上下班或者周末挤出时间来学习,每周需要新人与mentor一起聊聊本周学习的情况,以及确保对学习内容的理解是足够深入的。有些mentor会比较细致,要求新人将每个学习到的设计模式都能够画出UML图,并说明他们的适用场景。 第二部分需求代码,也是最重要的讨论部分,两人会就本周新增的代码进行详细的review。这和普通的代码review差异很大,主要在两方面,一是前期mentor对需求背景足够了解。二是每周过程中都会review,没有合并代码的时间压力,mentor可以给出设计上的建议,新同学也能够及时理解并修改,而不用担心需求无法上车。同时这也是线下review,新人与mentor之间会更多的互动。所以这个环节的效果非常好。我见过一个小组,上周review出来一个性能问题后,下一次就已经提了技术需求进行优化,反馈与处理都很及时。 最后一部分是这个周期中,代码质量话题群大家所抛出来的问题。之前观察到一个现象,或许由于发表话题同学描述过于简洁,或者由于新同学经验不足,很多新同学并不一定能够完全理解全部的问题,但有时又不好意思在话题群直接问,所以导致很多问题还是一知半解。这部分讨论的目的是希望在mentor的帮助下,新人可以充分的理解问题被抛出来的原因,以及如何处理才是最优解。因为话题群抛出来的问题通常是实际业务代码,新人可以就该问题与mentor一起深入探讨,为什么这样写不好,应该怎么写比较好。所以这个环节也非常有帮助。 小F:看起来的确如你所说,这与合码时的review还是不同的,也和平常的1on1有所不同。那为什么还要设置一个观察者?他们是监工吗?(微笑脸) 老Y:哈哈,并不是,从最开始启动小灶班计划,以及对人员的慎重选择上,就决定大家认同它是有意义的,不太可能存在懈怠的问题。如刚刚所说,每周小灶是我们这个培训最重要的环节,整个培训能达到什么样的效果便取决于它,所以我们希望每个小组的讨论足够充分。小组的讨论会有很多启发或者改进的地方,是能给到其它小组作为参考,观察者的设立意义便在此,如果遇到问题也可以更加及时的调整。其实每次讨论可能不仅有观察员,还有自由围观的其它人,我们把日程都贴在了文档中,整个过程透明,有种上公开课的感觉。 小F:为什么还要有月会?它的作用就是刚说的优化流程吗? 老Y:我们设计了两个月会,一是针对新人,一是针对mentor。新人月会上,我们采取了一种很有意思的形式,由新人两两组队,去review对方的代码,主要是为了检验各自过去一段时间的学习成果。这个月会每次讨论的非常激烈,被review的同学会说明我为什么这么设计,review的同学也会从自己的视角提出一些建议,最后会由主持人给出自己的看法。几次的新人月会下来,效果大大超出我的预期。 另外一个月会是mentor月会,针对每个小组过去每周小灶的情况进行总结,看是否有做的好,以及要改进的地方,为其它小组提供参考。所以这个过程中,我们也会对流程做一些小的调整,进度上也可以较好的把控。 小F:最后的考核环节我看是做了源码剖析,为什么选择这种方式?听说方案也变过? 老Y:是的,最初的版本并不是源码剖析,而是答辩,答辩的内容包含学习进展,本季度内需求质量的客观数据等,由mentor、leader和方向owner参加,但其实我并不满意这一版的方案,因为除了学习进展与需求的质量以外,其它部分并不符合SMART中的“Specific”,很难衡量做的好坏与否。于是在一个周末,我突然想到源码剖析这一形式。它可以将理论与实践很好的结合在一起,如果能够充分的理解开源库的设计哪里好,为什么这么设计,那么已经可以算是初窥门径。同时因为看懂源码与能够有条理的写出来并让他人看懂之间,要求高了很多,所以交付高质量的文章,目标明确,且与代码质量关联度很高,整体来说这是一个我非常满意的调整。 小F:如果三个月结束之后,如何将代码质量意识贯彻到日常的工作中,让大家能够了解到什么是好的代码,什么是不好的? 老Y:这是个好问题,我们的流程中有一个话题群,它的周期并不是三个月,而是一直存在。平常关于代码质量的吐槽也好,分享好的代码也好,都可以在这个群里抛出来并展开讨论。它承载了关于代码质量讨论的日常。由于这里面吐槽的代码大多是我们自己的项目,所以相比于公司的吐槽大群更加有体感,并且,这里并不是纯粹的吐槽,而是可以给出建议,讨论怎样可以写的更好。让大家在日常工作中明白,什么是团队所提倡的。 小F:看起来最初是为了提出代码review规范来解决代码质量问题,慢慢演变成了关于新手工程师培养。 老Y:没错。因为在思考这个问题时,越来越发现它并不仅仅是流程问题,流程只是其中的一方面。像很多同学所说的,流程我们都有,但是很难执行下去。于是我在想,为什么这些机制无法落地?得出两个结论,一是做这件事的人本身对代码质量的能力还存在提升空间。二是没有可落地的方式来保障执行过程。所以整体方案才从这两个方面进行展开,演变成了对新手工程师的培养计划。 小F:这个流程还是很复杂的,担心流程过重从而引起大家的反对吗? 老Y:这是个好问题,根本原因上还是对大家有帮助,新人能够提升代码质量,mentor后续也能够少操心。在设计过程中一直在思考如何既能够达到预期的目标,又能使得流程可接受。所以在每个环节中都尽量做了精简,只保留最基础的内容。各环节中的具体应该如何执行,没有做强制的要求,保留了足够的灵活性。比如学习的内容及节奏安排,每周小灶中讨论的事项,最后源码剖析的选择,都是设计了一个大的框架及目标,剩下的交给新人与mentor自由发挥。如果当周大家都很忙,也可以暂停一周。所以整体上还是给了很大的自由度。 在正发宣贯前,我也分别找了很多同学一起review过,确保了大家的可接受程度。 小F:这个“不过分占用时间”的流程,对时间的占用怎么样? 老Y:最占用时间的是每周小灶,mentor与新人需要每周抽一个小时时间,基本都是可接受的,对业务开发的打扰并不算大,相反可能mentor能够提供一些思路,帮助新人更好的开发需求,减少后期的返工成本。最后的源码剖析与理论学习环节会比较消耗新人的时间,大家都需要利用晚上下班或者周末的时间,这点上对新同学来说还是比较辛苦的,我们没有预留工作的时间。因为我订阅了所有文档的更新通知,发现很多同学都会在周末更新文档,但我想,这是成长必须付出的代价吧。 细节满满的落地 小F:小灶班的整个周期很长,我见过很多机制都是在长时间的运行中不了了之,最后形同虚设,看起来你们的小灶班并没有,而且这个流程还是挺复杂的,这里有什么经验吗? 老Y:首先还是大家对这个培训本身的认可度比较高,新人能够感觉到自己的成长,mentor能够看到未来自己模块的质量提升,所以大多还是靠自驱。另外,在执行过程中也有挺多细节,比如观察员与我的重度参与,包含每周小灶、月会、月度分享等,这让大家可以看到我们对这次培训的重视,也促使大家能够认真对待。还有一点很重要,每个环节之后都有及时反馈,做的好的及时给出鼓励,这也给新人们提供了一部分动力。整个机制中,所有的过程透明,包含1on1、月会、分享等环节的日历、文档、录屏,都统一记在文档中,小组的成员都可以随时查看。最后,每个阶段都有对应的产出,比如每周小灶与学习的过程跟踪文档,月会的记录文档,最后的源码剖析文档等,这起到一定的约束作用。多个因素共同促使整个机制顺利的运行。 小F:能详细下说你参与到其中的哪些环节中吗? 老Y:除了整个机制的建立外,我给自己定了一个原则:不能够做甩手掌柜,而是要深入参与到每个环节中。一方面是希望通过自己的参与,向团队传达一个理念,即我们对代码质量及新人成长的重视,另一方面是希望通过自己的观察,能够不断的改进这个机制。正因为如此,各个环节都有我的重度参与的身影。在第一个月的分享上,我也做了首次分享,并将分享总结成文章:浅谈代码架构设计,发布在ByteTech上。 小F:这应该也挺耗精力的,我看你也是每周小灶的观察员。 老Y:是的,我深度参与了其中一个小组的每周讨论,发现大家每次的讨论还是比较深入的,会针对某个具体的问题讨论,会前新手都会有充分的准备,在要讨论的代码处加上标记。经常也会出现会议冲突,遇到这种情况时我每次都会看录屏,对其它做的比较好的部分,以及建议部分,在内部做好同步。 小F:这些反馈非常重要。 老Y:是的,不仅对当前小组,对其它小组也有很好的参考性。同时,还能够对机制及时做出一些调整。每个小组的反馈都会在月会上再次过一遍,看是否有共性的问题。 小F:是的,这些正面的反馈在某种意义上说,也会帮助你更好的推进整个机制的运行。 老Y:没错。比如倾听,每次的小灶观察/月会等,观察员都会仔细的倾听所有人的发言,给出值得表扬的部分,以及建议,你的认真倾听,大家能够感受到,这一方面会让每个小组有小小的压力,另一方面,会让大家对这事更加重视,认可它们的价值,所以倾听本身也是一种反馈。然后是正面反馈本身,我们会在很多环节给出及时的反馈,尤其是最后的源码剖析环节,帮助大家在大部门内部打call,季度末评选获奖名单及发放奖品,多篇文章参与团队内的技术分享,这些充满仪式感的点滴,都给新人们提供了足够的信心。 小F:月会有什么值得分享下的吗? 老Y:首先需要明确月会的目的是什么,针对新人与mentor的作用是不同的。针对新人的前面已经说过,为的是新人互相以各自的视角给到他人的代码建议,这个过程中可以锻炼新人过去理论以及与mentor沟通时的掌握情况。另外,在新人互相沟通的过程中,因为大家经验相近,所以相对于mentor,大家提出问题或者接受建议的压力会更小,从几次讨论来看,过程都比较激烈。明显也能看到大家在这个过程中,可以提出有建设性的建议。 对于mentor的月会,主要目的还是了解新人的进展,以及过程中是否有需要对机制进行微调的部分。最后有一点很重要,每次会议的结论都会在群里同步。 小F:最后一个环节是源码剖析,如何保障大家的文章交付质量? 老Y:在最开始确定源码剖析是最终考察时,我们就确定了一个目标,每篇文章能够发表在ByteTech上,这点本身就对文章的质量提出了一定的要求。同时还通过几个方面来保障整体的质量,初始时,我会在群里给大家建议,应该如何写源码剖析文档,并给了一些网上不错的例子。开始写文章之后,每周小灶的会上mentor都会与新人一起对齐文章的进度,并给到一些建议。初稿完成之后,我给每篇文章分配了一名reviewer,为每篇文章提供建议。我也花时间看完了所有同学的文章,并给出大量的评论。最后,我在群里分享大家文章遇到的普遍问题,为大家的写作指明方向。经过几轮的修改之后,所有的文章都写的不错,并顺利发表在ByteTech上。 小F:看你经常在群里长篇大论,会担心大家觉得自己啰嗦吗? 老Y:被你发现了,不过我认为大部分是有意义的啰嗦,所有发言都希望言之有物,能够给到大家启发。当然,也会面临石沉大海的情况,这是不可避免的,只要认可这件事的意义,认可行为的意义,我觉得就够了,不一定需要得到所有人的认同,要避免这种心态。大家都是成人,可以独立判断信息对自己的价值。 感触颇深的结果 小F:三个月下来,整体的效果怎么样?达到你的预期了吗? 老Y:整个过程很长,效果体现在三个方面,一是过程,比如大家在1on1中所讨论的内容,话题群中的发言积极参与,以及最后源码剖析的认真对待。二是结果,计划初所设定的理论学习目标基本完成,最后的源码剖析交付质量也不错,全部发表到ByteTech中,其中有两篇还尝试代表团队为ByteTech十月刊投稿。最后,我们还设置了一份问卷,mentor与新人双方互相评价,以及对整个机制的评价都反馈非常正面。当然也不可否认,大家的反馈可能还是会偏向往正面写,但从过程与结果,已经远远达到我的预期了。 另外,我们发现这个机制并不仅仅是提升代码质量,它从更多的角度帮助了新人培养,很多环节所涉及到的能力提升并不仅限代码质量,比如1on1中的沟通,观察mentor如何做需求,源码剖析中的表达等。 小F:这次小灶班花了这么多心思,对你有什么启发吗? 老Y:是的,过程中有挺多感触的。这个方案几经周折,一直没有达到满意的状态,最后还是与几个同学的1on1中受到启发,才逐步完善。但这里有一个最重要的原则,如果没有它,小灶班不会达到我的预期,这个原则便是「可落地」。最初的代码review规范没能实施就是因为它不好落地,最新的方案也都是围绕着「可落地」的原则一一展开。如果不能落地,不要增加必要的流程,在没有满意的方案时,宁可不做,我认为这个坚持是对的,公司也倡导的是求真务实。 第二个感触:「具体」与「抽象」,我们经常说要「抽象」,层次要更高。但有时「具体」很重要,这次的小灶班中有很多「具体」的环节,比如每周小灶、最终考核。1on1讨论的内容与日常1on1不同,内容很具体,明确了需要沟通什么。当事情「具体」之后,便能做到言之有物,它是可落地的保障。而「抽象」是能够确保能够看到更高的层面,它与「具体」相辅相成。 第三个感触,没有银弹。没有任何一个机制可以简单的解决所有的问题,为了保证效果,我们根据团队的现状,配套设计了理论学习、月会、分享、话题群等,在我们精力的范围内,所有可能帮助到大家提升代码质量的方法,都运用在了这次的机制中。小灶班即使对我们团队有用,也不一定完全适用于其它团队。 最后,也是最大的感触,任何一个最终呈现,大家所看到的可能仅仅是冰山一角,它背后的波折、思考、纠结等都是未展示出来的,但有时又很有意义。所以我计划将我们这次对话记录下来,算是作为小灶班的花絮吧,也许对一些人会有启发。 小F:这是第一期小灶班,后续还有计划继续做吗? 老Y:这和团队现状有关,目前团队梯队已经比较稳定,新同学较少,所以不会再大规模的组织。但个别的新同学可以与mentor一起,从理论、每周小灶及源码剖析三个方面来选择开展,效果也会不错。 小F:还有什么对参加小灶班的新同学说的吗? 老Y:最想说的一点是,成长是自己的事情,机制可能会帮助你更好的执行,但效果如何,还是取决于这个过程中的自己。小灶班虽然结束了,但在成长的道路上,它仅仅是一个开始,大家依然可以主动与mentor一起按自己的节奏继续开小灶。 小F:如果团队没有组织这种小灶班,但我想参考小灶班的形式,有什么建议吗? 老Y:如果你是新同学,可以直接找mentor,其实这里面最重要的机制是三部分,理论学习、每周小灶及源码剖析,其它环节都围绕着这三点,对于理论与源码剖析,新同学可以独立完成,每周小灶可以找mentor帮助。如果你是mentor,希望提升新同学的代码质量,也可以两人一起挑选有用的并尝试这个计划。 小F:非常感谢你的耐心解答,我也打算尝试下这个机制,希望能够解决目前的问题。 老Y:不客气,欢迎随时交流~ 写在最后的唠叨 很喜欢看八九十年代香港电影的片尾,因为片尾中经常会展示电影拍摄过程中的各种花絮,有被忍痛舍弃的镜头,有笑场的NG,有与角色充满反差的演员日常,甚至有时导演会将这些镜头拍成一部纪录片,为观众讲述电影背后的故事。《星际穿越》的导演诺兰特别擅长此道,他的很多电影都有与之配套的高分纪录片,比如《走进「星际穿越」》、《黑暗骑士的完结》等,这些背后的故事让观众能够更好的理解影片,看完后会对电影有很多不同的感受。 工作中经常遇到的流程、决策也类似,最终展示出来的结果只是冰山一角,有时很想了解这些决策背后的思考是什么,为什么如此设计每一个环节,是一时的兴起,还是深思熟虑之后的结果。这,便是本文的初衷,希望讲述有关新人培养的思考,想展示这个简单计划从最初闪现到最终落地的波折,以及想透过它传达出的理念,这篇文章权当代码质量小灶班的花絮了。 至于为什么采用虚构的对话形式?这大概是一时兴起,希望为讲述增加一些趣味,同时用对话更轻松的将各个话题串连起来,读起来大概不至于枯燥吧。 然而,纪录片也许早已注定了它很小的受众,但,对于热爱那部电影,想了解关于电影一切的观众而言,它却是极为珍贵的补充,这也是本文意义的所在。 (全文完) feihu 2024.10.20 于 Shenzhen

2024/10/21
articleCard.readMore

浅谈代码架构设计

老Y是一个有着十多年经验的软件开发工程师。下面是老Y和代码架构设计有关的故事。 在软件开发者的职业生涯当中,一定会遇到大量关于代码架构方面的名词,比如“可维护性”、“可扩展性”、“高内聚低耦合”、“组件化”、“分层架构”、“SOLID”等等。在短短几十年的计算机历史中,涌现了大量有关软件架构的经典著作,对架构进行了极其全面又深入的讨论,这些著作基本来自于在软件行业有着数十年经验的大神们。既然已经有了这些全面、系统、深入的架构著作,为什么还要写这篇文章? 从小有句话我们可能听了无数遍——实践是检验真理的唯一标准。即使读过了这些著作,听了大神们的分享,我们依然不能说自己掌握了软件架构的设计。好似那些自己不亲自踩过坑就无法懂得的道理一般,只有在编码的道路上不断实践,不断将理论与实践结合,才能够做到真正的掌握。所以本文仅站在一个普通软件开发者的角度,从架构设计上的一些经历和踩过的坑出发,来谈谈代码设计,讨论在追求更好的架构设计道路上的那些痛苦与挣扎,以及豁然开朗的时刻。 老Y说:在写代码的路上,始终要记得一点,“代码是给人看的,而不是机器”,所以那些奇技淫巧并不推荐。 目录 井底之蛙 数千行的main MFC启蒙 MVC or MFC 初出茅庐 读万卷书 代码大全 重构 行万里路 理解抽象 日志系统 单元测试 小结 多媒体缓存 好的命名是成功的一半 责任链 再聊责任链 状态机 随后的故事 谈谈业务开发与架构 写在最后 一点推荐 源码 书 井底之蛙 老Y在大学时是电信专业,和编程有关的课程只有C和C++,C++还是一门选修课,对于网络、操作系统等全然不知,一直以为学好C/C++,有一定的逻辑思维,就算是进入了软件开发行业的大门。彼时写的代码都是一个个允长却又逻辑简单的函数,像是用编程语言写的菜谱。 数千行的main 估计和很多人一样,老Y在学校做过最大的项目是毕业设计,选题是基于计算机视觉的交通流量检测系统。在有了方案后,老Y根本没想着要如何去设计这套系统,而是“文思泉涌”般立刻开始编码。因为其中要运用大量的算法,处理流程也非常长,所以最终的代码量有数千行。但那时老Y在开发上并没有模块的概念,所以整个代码库只有一个main.cpp文件,几千行代码堆满其中。文件中充满了无数的函数、全局变量,老Y根本不知道什么是设计,它就像是逻辑的自然倾泻,也只有老Y自己才能看明白,可维护性极差。后来他才知道,这种编码还有一个名称,叫过程式编程。老Y甚至还把C++当成C语言来使用,摒弃面向对象特性。 虽说代码写的很差,但在这次的毕设过程中,老Y对软件开发有了初步的认识。“程序=数据结构+算法”,忘了在哪里看过的说法,这次他对这句话颇有理解。因为毕设项目中需要运用大量的算法,所以老Y寻找了很多的论文,并实现其中的算法。为了让算法能够更容易传递数据,老Y也运用了多种数据结构,最后好像整个项目都由它们构成。记得一次和老Z去吃饭的路上,老Y说这时候才理解“程序=数据结构+算法”。不过现在看来,这句话是在一定的语境下出现,存在片面性。 MFC启蒙 大部分软件开发都离不开GUI,而老Y最初接触的GUI框架是MFC(Microsoft Foundation Class),估计很多人都没听过,它曾是微软关于Windows编程的官方C++ GUI库,集成在Visual Studio中,就好比现在UIKit集成在Xcode中一样。MFC可能是很多人了解架构的启蒙,老Y也不例外。 在新建工程时,MFC会默认创建CDocument、CFrame、CApp等文件,开发者只需依葫芦画瓢,在不同地方填写不同的代码即可。但在使用了很长时间后,老Y也没理解为什么要这么划分,反而认为这样散落在不同文件中的设计,充满了诸多不便。而且MFC中包含了大量tricky的宏定义,导致对其运行机制的理解更是难上加难。 直到老Y后来读了侯捷的《深入浅出MFC》,才知道MFC设计的精妙。即使现在MFC已经退出历史舞台,但它不失为学习架构设计的一个非常好的例子,作为微软的官方GUI库,它也在软件开发的历史上留下了浓墨重彩的一笔。顺便一说,侯捷写/译了大量关于C++的经典著作,尤其擅长源码剖析,比如《STL源码剖析》、《Effective C++》、《深度探索C++面向对象模型》(老Y曾被这本书折磨的死去活来),可以说是在C++能够在中国传播最重要的人物,任何一个深入学习过C++的软件开发者可能都听过他的名字。老Y也是在读完《STL源码剖析》之后,写下了知无涯之std::sort源码剖析。 MVC or MFC 老Y在毕业找工作时经历过一次印象深刻的面试,面试官问,MVC是什么?大家可能觉得这是送分题,老Y当时却为他的浅薄无知付出了代价。他大言不惭的想,是不是面试官说错了,应该是MFC吧,并且真的把自己的想法说了出来,现在每每回忆起来都觉得无比尴尬,不知道面试官当时内心活动是什么,只会觉得这傻小子无知且迷之自信吧。但可能是其它方面打动了面试官,最后老Y拿到了这家互联网公司的offer。 那是第一次有人问老Y代码设计的问题,过去更多是问算法、数据结构等计算机基础。老Y在准备面试的过程中,也从来没准备过相关问题,这才闹了笑话。 老Y说:无论校招还是社招,面试都会看整体表现,不会因为一两个问题没回答好而不通过。 初出茅庐 老Y毕业后,加入那家面试时问MVC的互联网公司,做移动端视频App开发。此时老Y已经有了初步的模块意识,会将不同功能放在不同文件中,而不是像毕设时将所有的代码堆在一个文件。这么多年过去,有一个项目中的源代码老Y还时常回忆起,叫movie_data.c,它相当于MVC中的model层,作者就是老Y。 从文件命名中可以看出,它用于处理视频的数据,包含多种场景,从服务端请求、到xml解析、到model的转换等。老Y还清晰的记得当时每个场景的函数,都是一个长达十几种条件分支的巨大switch case,每个场景都有着类似的代码结构。 // movie_data.c void function1() { ... switch (type) { case A: { } case B: { } ... case Z: { } } } void function2() { ... switch (type) { case A: { } case B: { } ... case Z: { } } } ... void functionN() { ... switch (type) { case A: { } case B: { } ... case Z: { } } } 相信你已经可以看出这里的问题,老Y在当时已经觉得写起来很奇怪,不仅每个函数允长,且存在大量重复的分支逻辑。改动起来也很痛苦,每增加一个type,就得所有的方法都加一条case语句。但当时由于开发时间紧张,又没有任何架构设计方面的理论支撑,老Y并不知道如何优化。现在回头来看,一切显得那么简单。可以将同一类型的处理封装到一个模块中(那时用的是C,没有class),然后再根据不同的场景类型来注册handler即可。这是老Y首次在工作中意识到除了有数据结构和算法以外,还有架构。 老Y说:每个人都是如此摸爬滚打成长的,时刻处于无知与更大的无知之中。 读万卷书 代码大全 后来老Y加入了一家外企,因为节奏比互联网慢很多,所以老Y便利用空闲时间,恶补了大量本应在学校期间就掌握的知识,将各个领域的经典著作看了一个遍,这其中就包含了对他影响最大的《代码大全》。除了感叹于它的大而全,其中印象最深的有三点: 代码设计就是管理复杂度 表驱动法 DRY原则 第一点管理复杂度,如上面的movie_data.c,它将处理type的复杂度暴露给了每个场景的方法,所以每个场景都需要去switch case一番。再如毕设期间长达几千行的main.cpp,也是因为复杂度过高,所以需要将它拆到不同的模块中去。移动端开发的MVC、MVVM等,将模块抽象成Model、View、Controller等,都是期望降低某一个模块的复杂度,在一个模块进行修改时,不需要感知其它模块。代码中的入参也同样,超过了一定的数量后,如7个,那么就需要考虑将它们放在不同的结构中了。甚至一切设计模式都是用于管理软件复杂度的手段。 第二点是表驱动法,这是一个至今依然在大量使用且极其高效的方法。还是以movie_data举例,可以将每个场景的处理封装到对应的方法中,然后通过表查询来处理: // movie_data.c void registerHandlers() { handler1Map = { // 将类型与对应的处理封装到字典中,使用时直接通过类型查到对应的handler A: handler1A, B: handler1B, ... Z: handler1Z }; handler2Map = { A: handler2A, B: handler2B, ... Z: handler2Z }; ... handlerNMap = { A: handlerNA, B: handlerNB, ... Z: handlerNZ }; } void function1() { Handler handler = handler1Map[type]; handler(); } void function2() { Handler handler = handler2Map[type]; handler(); } ... void functionN() { Handler handler = handlerNMap[type]; handler(); } 可以看到,上面的代码清晰了很多。每个场景的funciton不再需要去处理switch case的逻辑,所有的switch case都通过表查询handler1Map[type]解决,函数内部的复杂度立刻降了下来。 甚至可以进一步的将同一种类型的处理进行封装,以减少不同类型之间的耦合。 // modelA.c void registerAHandlers() { // 所有A相关的处理都位于该模块中,对其它类型都不感知 handlerA[] = [ handler1A, handler2A, ... handlerNA ]; } ... // modelZ.c void registerZHandlers() { handlerZ[] = [ handler1Z, handler2Z, ... handlerNZ ]; } // movie_data.c void registerHandlers() { handlersMap = { A: handlerA, B: handlerB, ... Z: handlerZ }; } void function1() { Handlers handlers = handlerMap[type]; Handler handler = handlers[0]; handler(); } void function2() { Handlers handlers = handlerMap[type]; Handler handler = handlers[1]; handler(); } ... void functionN() { Handlers handlers = handlerMap[type]; Handler handler = handlers[2]; handler(); } 再进一步的优化可以在拿到数据后立刻获取该类型的所有handlers,后续所有的function中不再传递type,而是直接传递handlers。如果是面向对象语言,可以在创建多个子类,然后由工厂方法根据type创建一个子类的实例。 对本书印象较深的第三点是DRY原则,大家可能都听过它,Don’t Repeat Yourself,字面意思非常简单,不要重复。重复的意思可以有很多种,比如不要有重复的代码,强调代码的可复用性。工作中有很多这样的场景,要新开发一个功能,发现之前有人写过类似的方法,那么是直接快速拷贝一份代码,改几行?还是对方法进行抽象,将可复用的部分下沉,提取出参数来区分不同的逻辑?同样运用到上面的movie_data中,前期的movie_data每个场景都有大量的switch case语句,每次进到一个函数中都要处理一轮,重复的逻辑处理。 DRY并不一定是在开发过程中才经常用的原则,它也可以用到其它场合。老Y有段时间需要在一个App上查询社康是否有某个疫苗,查询了全市很多社康都找不到,因为很着急打这个疫苗,所以他连续每天都要搜索一遍。操作不算复杂,但就是比较繁琐,App不支持查询功能,需要手动切换社康查看。为了避免每一次的手动操作,老Y花了大约一天的时间写了个爬虫,每天定时将全市的社康都查一轮。这两种处理方式的总耗时可能差不多,也许写爬虫还会多一些,但其中的思维却有着比较大的差异。 老Y说:最后再强烈推荐大家去读一读这本900多页的“砖头”,以上的介绍不及此书的万分之一,作者不仅把软件开发的方方面面讲了个遍,并且文笔极好,读起来十分顺畅。 重构 除《代码大全》以外,老Y在此期间还读了大量设计模式相关的书,比如四人帮的《设计模式》、《重构》、《代码整洁之道》、《HeadFirst设计模式》等,积累了很多关于代码设计的理论知识。 在所有这些书中,老Y印象最深的是在《重构》中读到的“坏味道”。如果让我们说出一道菜为什么好,可能会有点难度,但如果让我们说出一道菜哪里不好,那可就容易多了,可能这是人类的天性,我们对于找茬更加擅长,对坏味道可能比好味道更加敏感,而当我们发现这道菜中的所有坏味道并一一去除,这道菜便离美味不远了。代码亦是如此,如果熟悉了这些原则,在实践中不断训练自己找到代码中“坏味道”的能力,发现之后,再尝试用这些原则与模式对其进行重构,写出好代码的可能性便大大提升。 老Y说:23个设计模式是“术”,是手段,7大设计原则是“道”,掌握它们之后,想要进行架构设计还需要知道力该往哪里使,于是需要找出“坏味道”的“嗅觉”。最后再加上一点“洁癖”,这是动力。 行万里路 纸上谈来终觉浅,觉知此事要躬行。有了大量的理论“锤子”支撑之后,老Y便开始寻找一切能看到的“钉子”,开始了行万里路的阶段。 理解抽象 在所有降低代码复杂度的方法中,抽象也许是最重要的一个,甚至可以说软件的本质就是抽象。我们平常会抽象出来各种函数、类、数据结构,其核心目的依然是为了降低复杂度。 整个软件开发都是在做抽象,从底层往上层看,高级语言是对汇编的抽象,降低对汇编理解的复杂度;高级语言提供的标准库,它是抽象出来降低开发者对底层的复杂度;网络模型是抽象,它用七层抽象依次将上一层的复杂度控制在一定范围内;基础库是抽象,比如网络库将网络相关的能力基于网络模型进行封装,降低了网络使用的复杂度;业务模块开发也是抽象,业务代码经常会划分为原子能力层、组件层、业务层等,理想状态下每一层都只会与它下一个层级打交道,从而极大的降低每一层的复杂度。 同样,不管是设计原则还是设计模式本质上是讲如何做抽象,比如前面提到的DRY原则,目的就是将重复的部分抽象成可复用的逻辑,让每一个上层仅关心这个复用逻辑即可。前面提到的表驱动法也同样,它将switch case抽象到了一个字典中,使每个场景不用去关心不同type的分支逻辑。 在这家外企,老Y通过几个项目对抽象有了进一步的理解。 老Y说:软件开发目标是降低复杂度,一切手段的本质是抽象。 日志系统 首先是日志系统,它是每个软件中必不可少的基石。读到这里,先停一下,如果要你要来设计一个日志系统时,你会怎么做? 之前老Y写过一篇文章来探讨日志,从0开始一步步搭建起日志系统。它从简单的printf出发,逐步增加需求,在一个个问题得解决之后,便抽象出很多概念,比如TraceLevel、Marker、Appender、Formatter、Category等。这看起来非常复杂,增加了开发者的理解成本,但当其背后的思考在揭示之后,你便可以理解它的强大。正是这些概念的引入,使得日志系统能够满足各种需求,且能够帮助开发者方便的进行扩展。Python的logging设计也是类似。 值得注意的是,它们并不是一开始就设计成这样,而是随着需求的增加,一步步抽象而成。软件开发并不提倡过度设计,它提倡的是最高效的满足当下需求的同时,又具备较好的可维护性与扩展性。比如如果是写一个控制台的demo,直接用printf就够了,不需要引入那么复杂的概念。 下图是核磁共振设备上运行了几十年的日志系统,这也是老Y迄今为止遇到最为复杂的日志设计。它不仅有一个总的日志文件,每个模块还有各自的日志,同时左边是一台Windows,右边是一台Linux,两者可以完美的协作,并可以在Windows上实时的查看Linux上的运行日志。这其中大量的运用了观察者模式,如Appender。更多细节可以在深入理解log机制一文找到。 老Y说:类似于上图的系统看起来极其复杂,直接去看代码肯定会懵掉,在理解各种概念之后再去阅读源码,一切会变得简单很多。 单元测试 单元测试框架也是每个语言所必不可少的组成部分,它为单元测试提供了最重要的支持。同样,在这里停下来思考一下,如果要你来设计单元测试框架,你会如何做? 熟悉它的同学可能会了解到框架中同样抽象了大量的概念,比如TestSuite、TestCase、TestRunner、TestResult等,而且这些概念在几乎所有语言的单元测试框架都类似,比如Java的JUnit、C++的CppUnit、Xcode的XCTest等,原因是它们都源自于同一个库,JUnit(再往前可延伸到SmallTalk的测试框架)。可以看看它的作者,Kent Beck与Erich Gamma(后者是《设计模式》一书的作者、Eclipse的作者、vscode的作者),《代码整洁之道》的第十五章《JUnit内幕》讲述了框架设计背后的故事,两位大神竟然是在一次3小时的飞机旅程中写出了这个框架。 上面是它的类继承关系图,每个类都有其单一的职责,并运用组合与继承,结合在一起: TestCase是一个具体的测试用例 TestSuite是多个TestCase的组合 TestRunner负责运行所有的Test/TestSuite TestFactory是一个创建工厂等 从图上也可以看出这里运用了几种设计模式: TestSuite是组合模式,将TestCase组装成一个树状结构 TestFactory工厂模式 另外还有未画出来的结果通知,采用了观察者模式 小结 以上两个系统都是短小精干的代码库,读起来很快,画几个UML图就可以非常容易的理解它们的设计。它们娴熟的运用了各种设计原则与模式(毕竟其中之一是设计模式的作者对吧),读完定有收获。 多媒体缓存 好的命名是成功的一半 如果说软件开发中什么最难,命名可能首当其冲。相信大家都有过被各种仓库、类、函数、变量命名折磨过的经历,老Y也不例外。这里想讲述一段老Y有关命名的经历,看起来只是一个命名的问题,实际上不同的命名让整个代码的作用有着完全不同的解释。 老Y当时面临的问题是要重构支付宝App中的多媒体缓存,这个库要处理包含文件、图片、语音、视频等多媒体资源的缓存。这里有一个逻辑很复杂,每个资源有两个id,一个叫做cloudId,它是该资源在服务端的id,可以用这个id去下载对应的资源。另外还有一个id叫localId,它是资源在上传之前,存储于本地的id,比如对相册中的图片进行压缩之后,会放入缓存中,需要一个key来获取该资源,这个key就是localId,表示其在本地的资源id,等到上传完成后,会从服务端获取一个对应的资源id,即cloudId,最终被关联到本地的缓存资源中。业务侧可以用这两个id中的任何一个来获取缓存资源。原有的存储与查询接口都分别有localId与cloudId的不同API,给使用方也带来了一定的理解成本。 读到这里你是否嗅出了很重的“坏味道”? 随着业务对缓存使用的复杂度越来越高,原有的缓存面临着重构,设计数据库的字段与缓存接口时,无论如何老Y都接受不了这两个命名,localId与cloudId,总觉得它们很奇怪,带有太强的实现细节。老Y认为一个更好的设计是不应该暴露内部的实现细节。而且作为一个缓存库,不应该理解local与cloud的概念,所有存储的内容在缓存这个抽象层就仅仅是一个资源而已,local与cloud属于更上层的业务逻辑。所以他反复拉着同事讨论,这两个字段也迟迟未能确定下来。 如何让一个缓存资源能够被两个不同的id找到?如何在缓存资源中将这两个id关联起来?这就成了老Y设计时考虑非常久的问题,如果这个问题不解决,重构再往前推进也面临着很大的困难,老Y认为这里是设计的关键所在,它也决定着底层的数据库设计,所以并不仅是一个名字那么简单。 念念不忘,必有回想。近一周后的一天,老Y灵光一闪,想到了Linux Shell中的alias,它可以为一个命令创建别名,无论是输入别名还是原有的命令,都表示同一个意思。老Y想,这里原来的localId与cloudId是否可以采用同样的方式,互为别名,老Y为自己的这个想法拍案叫绝。因此就有了这样的数据库字段设计:key作为资源的常规标识,同时增加一个叫alias_key的字段,作为该资源的别名,与key有同等作用。如此一来,下载资源时,资源会以key存在在缓存中,这里是key即原有的cloudId字段。在上传时,处理完的相册图片可以采用key来存储资源,即原有的localId字段。在上传成功后,要关联服务端返回的cloudId时,就可以将cloudId设置到alias_key的字段。这样不管是数据库,还是接口,都对业务层的逻辑不再感知,不管是local或者是cloud,缓存只认key和alias_key,相当于通过提供alias的能力,让缓存能够处理原有的localId与cloudId,看起来优雅了很多。 接口设计上也只有根据key来查询资源或者存储资源的接口,同时增加设置别名的接口,用于关联两个key,仅此而已。使用key还是alias_key去查询,这些逻辑全都被封装在缓存的内部进行处理,业务侧不再需要考虑这个key是local还是cloud,极大的简化了接口,方便了业务侧的使用。 在突破了这个“难题”之后,后续的设计就变得简单起来,老Y也一气呵成完成了整个多媒体缓存的设计。后面也果然遇到另外的需求也需要两个key,而且不再是localI与cloudId。需求是当一个用户替换了头像,在新头像下载完成之前采用旧的头像,此时可以用这一套缓存很方便的解决,即可以给旧头像设置alias_key,当新头像未能下载成功时,缓存会从alias_key中去查找到旧头像,于是快速的实现了这个需求,甚至不用修改一行代码。 这个命名问题给老Y留下了很深的印象,让他坚信对命名的坚持是有意义的,而且它不仅是一个名称的问题,而是可能左右整个设计的关键,它决定了设计抽象的好坏。 老Y说:如果没有经历过命名上的绞尽脑汁,可能也永远无法体会想出一个合适名字时的愉悦感。 责任链 继续多媒体缓存的设计,除了命名以外,老Y还有一个印象深刻的设计问题。 图像缓存的查找在多媒体图像业务中占据着核心的位置,所有看到图片的地方都离不开它,它的性能左右着整体产品的体验。可以想象如果在刷小红书时,图片卡顿对体验造成的负面影响。 图像查询的逻辑也极其复杂,大致存在这样的查询顺序:对应图像的q值->等比图->原图->大图->其它更大尺寸需裁剪的图->图像key的别名等,由前向后,当前的条件不满足时fallback到下一个,当用原来的key没查到时,需要再用alias_key重新查一轮,没有精确的查询到时还需要找到比它大的图再进行裁剪处理。这里再停顿一下,如果是你,你会怎么写这段查询逻辑? 直观的写法是采用一个巨大的查询逻辑,依次按照上面的顺序进行查询,这里是否又嗅到了“坏味道”? Image *getImage(char *key, Size size, int qValue) { Image *cache = getCache(key, size, qValue); if (cache == NULL) { cache = getCacheWithAliasKey(key, size, qValue); if (cache == NULL) { cache = getScacledImage(key, size); if (cache) { // crop image } else { cache = getOriginalImage(key, size); if (cache) { // crop image } else { cache = getBigImage(key, size); ... } } } } return cache; } 上面的代码可以看到查询逻辑非常复杂,每个查询的代码会受到其它查询的影响。该方法本身已经足够臃肿,如果要调整查询顺序时都需要在该方法中去修改,容易出错。这样将导致代码极难维护,且难以扩展。 我们来看怎么对这个问题进行抽象,这里的每一个查询可以作为一个单独的类/方法,它们不需要关心其它的查询是怎么进行的,只需要给定入参,并返回一个结果即可,所以可以抽象成一个查询类,每个查询都对应着一个子类。而查询像是一个链条一样,当前查询完成之后,如果未完成就进行下一步的查询,直到所有的步骤都完成后,返回结果即可,老Y想起设计模式中的责任链模式。在图像缓存创建时,将这个责任链组装起来,后续所有的查询只需过一遍责任链。从扩展性上来说,调整某个查询的逻辑时只需要在对应的查询子类中处理,不影响其它的查询。如果要修改查询顺序或者增加新的查询,只需在构建责任链时处理。 class Querier { public: Image *query(char *key, Size size, int qValue) { Image *image = getCache(key, size, qValue); if (image == NULL) { image = getCacheWithAliasKey(key, size, qValue); if (image == NULL) { if (next) { // 责任链的下一个节点 image = next->query(key, size, qValue); } } } return image; } // 真正的实现在这里,子类继承 virtual Image *queryImage(char *key, Size size, int qValue) = 0; Querier(Querier *next): _next(next) {} private: Querier *_next; }; class QValueQuerier { public: Image *queryImage(char *key, Size size, int qValue) { // 根据Q值查询 } }; class ScacledImageQuerier { public: Image *queryImage(char *key, Size size, int qValue) { // 查询等比图 // 裁剪 } }; // 其它查询器 ... Querier *querierBuilder { // 初始化缓存时构建查询链 Querier *querier = new QValueQuerier(new ScacledImageQuerier(xxx)); return querier; } // 查询 Image *getImage(char *key, Size size, int qValue) { return querier->query(key, size, qValue); } 通过对每种查询的抽象,封装出一个责任链模式的查询链路。看起来代码好像长了很多,但每个查询之间完全隔离,复杂度控制在查询内部。而对于图像缓存来说,它的抽象层所关心的是这个查询逻辑,并不关心每个查询具体的实现,所以对于它来说复杂度也很低,只在于查询链的构建。代码结构非常清晰,易维护,易扩展。 再聊责任链 责任链是一个非常常用的设计模式,老Y除了在上面的多媒体缓存中用到以外,还有一个场景也让其印象深刻。 当时在做支付宝的AR,这里仅讨论其中关于相机的处理部分,下图是一个典型的AR场景,画面上可能会有一些输入源,如相机画面、3D模型、水印、贴图等,画面也会有一些特效处理,比如美颜、前置相机的镜像等,最终画面可以预览到屏幕上,也可以保存到文件中。 最初的设计很简单,提供了一个ARCameraView,它的功能非常“全面”,能够控制相机、画面增加美颜、录制视频、渲染上屏等。对于使用者来说,看起来是比较简单的,只需要不停的设置属性,它像是一个Facade,所有的实现都在ARCameraView的内部处理。 有嗅到其中的坏味道吗? 看起来很简单也很直接对吧,可这个设计存在很多问题: 它的扩展性非常差,每新增一个能力,比如增加一个滤镜、特效等,都需要开放新的接口,最终这个ARCameraView将变得非常庞大,接口也很多; 对于业务方而言,想要自定义流程十分困难,因为很多逻辑都是固定的; 对于实现而言,ARCameraView的内部实现非常复杂,如果想改一下渲染的流程将大动干戈,shader的复杂度极高,濒临不可维护的状态。 于是在一个五一的假期,老Y铁了心将这个类进行了重构。思想参考了移动端有名的渲染框架GPUImage,将整体渲染链路抽象成一个责任链,每个节点只专注于自己的逻辑。并引入三个概念,Source、Functor与Output: Source是数据源,比如相机、图片、视频等,它们是数据的源头,产生数据给后面的节点进行消费; Functor类似于一个handler,它接收一个数据,处理完成后再输出一个数据,比如滤镜、美颜等能力; 最后的Output是输出结点,一般位于链路的最后,接收前面节点产生的数据,不再向后传递新的数据,比如录制、预览等。 经过这样的抽象之后,ARCameraView的复杂度得到了显著的降低,可扩展性得到大幅提升,开放的接口也急剧减少,每种能力只需要关心自身内部的逻辑。业务侧在初始化时将这些能力组装成一条责任链,开启相机之后便开始加载数据。当新增一种能力时,比如抠像特效,只需要增加一个抠像的functor即可,业务层将它插入到链路中,现有的能力和链路不需要任何修改。 对了,关于给这个代码库取名老Y也极费心思。因为这里的整条链路都是在和GPU打交道,所以取名Texel,它本意是GPU纹理中的一个叫“纹素”的概念,对应于图像中的Pixel“像素”,老Y甚至后来还将它印到了球衣上。 状态机 状态机是非常常见的一种设计模式,老Y曾在一个视频播放器的项目中使用过。因为是基于系统的AVPlayer进行封装,所以不需要涉及到播放器解码等更底层的能力。当时还没有在线播放的能力,仅仅是从服务端下载视频文件,下载完成后才可以播放。假如这里不用状态机,会怎么实现,比较直观的实现如下: enum Status { Inited, NotAvailable, Available, ... }; void playVideo() { switch (status) { case Inited: { // get video and play break; } case NotAvailable: { // downloadVideo and play break; } case Available: { // get video and play break; } ... } } void downloadVideo() { switch (status) { case Inited: { // get video break; } case NotAvailable: { // download break; } case Available: { // do nothing break; } ... } } 是否嗅到了“坏味道”? 没错,这就和前面的movie_data一样,每一个方法都包含了大量的状态分支,每种状态都要处理不同的逻辑,极其复杂。换一种思路来看,如果不是从事件出发,而是从状态出发来思考,每个状态应该响应不同的事件,播放器的状态也会随着不同事件的驱动而发生变化。那样每种状态就只用关心如何响应对应事件,以及会向哪个状态扭转。 找到了一张当年画的图,现在看起来还是很简单。再从本质上来看,状态机依然是在降低复杂度,它将状态内部的处理逻辑封装到一个类中,每个状态仅响应特定的事件,并且只知道它可能跳到哪个状态,对于其它的状态完全不用了解。而如果是采用过程式的处理方式,可能要在每个方法的内部增加判断,导致每个方法都变得很复杂。 老Y说:因为设计模式很多,经常会出现想到用某个设计模式可以实现这个功能,但是忘了该模式具体如何实现,此时可以再去翻翻设计模式的介绍。所以不需要完全记得所有设计模式的实现,更重要的是要知道每个设计模式可以解决什么问题。 随后的故事 老Y在随后的工作中还遇到了很多代码架构的设计工作,从AR到游戏引擎,从责任链到跨平台DSL调度框架,此时的问题已经不能用单一的设计模式解决,而是会大量的运用设计原则、设计模式的组合。但它们的本质是一样的,就是倾其所能来降低复杂度,如果将它们一一拆解下来,最终还是会回归到这些原则与模式当中。这里由于篇幅的原因,不再对后面的几个大型项目展开。 老Y说:架构能力是无数次绞尽脑汁的堆积,它是抽象能力与业务理解的结合。 谈谈业务开发与架构 很多同学是偏业务的开发,有时会自我调侃就是个画UI的,如何在这种类似“搬砖”的工作内容下提升自身的架构能力? 老Y想将业务的开发(也不仅是业务开发,所有开发都是如此)分成四个层次: 能打:王宝强的封于修,动作凶狠凌厉 又帅又能打:赵文卓的聂风、法海,不仅能打而且动作飘逸 又帅又能打又有内涵:李连杰的黄飞鸿,动作飘逸自不必说,更是一代宗师 又帅又能打又有内涵又有影响力:李小龙,让世界认识了中国功夫 第一个层次是开发的基本要求,保证需求高质量交付,完美实现需求文档。但在代码设计上考虑的比较少,功能能够正常运行少出bug。 第二个层次在层次一的基础上更进了一步,除高质量交付以外,它在代码设计上有更多的要求,千方百计让自己的代码更加优雅,具有可维护性、可扩展性,看着赏心悦目。小到一个变量的命名,大到整体框架的设计,都对自己提出了高要求,好似“洁癖”一般,不达到自己满意的程度就浑身难受。 层次三在二的基础上,会深入到使用的各种库,去了解它们的原理、设计,掌握之后在自己的开发中可以举一返三,可以说它是通往下个阶段必不可缺的。移动端开发有大量的优秀开源库,如iOS的SDWebImage、AFNetworking、Masonary、Lottie、YYCache等等。比如说SDWebImage,为什么要设计成多级缓存,它再和YYCache相比,淘汰策略有何不同,是否有更好的设计?它们都是将理论与实践结合起来最好的范例。开源代码是最好的学习材料,老Y从GPUImage中学了责任链,从mediapipe中学了Scheduler,从游戏引擎中学会大型框架的设计等。现在就可以行动起来,可以找一个自己常用的三方库,去看它的源码,画出UML与时序图,在掌握了理论之后,这是最好的学习架构的方式,没有之一。 最后一个层次是将自己开发过程中可复用的部分沉淀为SDK,甚至开源出去给到其它人使用。我们日常使用了大量的开源库,很多都是来自于在实际的开发过程中,将问题抽象到一定的层次,从而整理成一个专门的框架来处理类似的问题,继而开源,这方面的例子数不胜数,比如: Airbnb的lottie Facebook的react native和fishhook 微软的PLCrashReporter 阿里的Weex 微信的JSPatch … 这个层次要求会很高,它需要你对一个领域有比较深的理解以及出色的架构能力才可能做到。但可以先从自己的代码能够比较好的复用做起,可以是小到一个UI组件的复用,比如一个特定场景的控件,也可以是大到一个动画框架如Lottie,关键是锻炼这种思维。不积跬步,无以至千里。 老Y说:最好的代码架构是重构出来的,并不是一蹴而就,所以在刚开始不需要过度设计,去考虑未来很多年的变化。当业务越来越复杂,人们不再满足于当下的设计,根据对业务的理解逐渐迭代架构以满足当下与将来的需求,必要时可能全部推翻重来。 写在最后 即使读过一百本书,听过无数次分享,可能不经历过一次就无法真正的学会一件事,人的大脑好像会对这些不产生于自身的道理天生就持抵制态度一样,“我知道”与“我真的知道”之间隔着一道必须通过亲身经历才能跨越的鸿沟。架构亦是如此,关于架构的著作读起来有时会让人觉得过于高屋建瓴,有时会全面又深入细节,如果没有实践的支撑,很多理论难以产生共鸣。所以开发者必须长期战斗在编码一线,不断去思考、总结、沉淀,没有谁能够仅凭理论就拥有不错的架构能力。 以上这些经历正是老Y在编码路上一次次既痛苦又兴奋的时刻,从架构设计的角度来说,这些观点既不全面,也可能有失偏颇,甚至它们仅仅停留在代码设计的层面,远未谈到架构。对于大部分人来说,这些经历也过于稀松平常。如文章开头所问,为何还要写? 对于老Y来说,每一次经历都对他理解架构产生了深远的影响,所以即便过去多年,它们依然历历在目,就像乔布斯所说的Collecting the dots,在未来的某一刻它们可能会发挥意想不到的作用。对于文章前的你,这些经历能够展示出老Y在面对这些设计问题时如何思考,对初窥架构之门的你也许会有所启发,帮助你找到属于自己的那些烙印时刻,因此成文。 (全文完) 一点推荐 源码 源代码是最好的学习代码架构的方式,没有之一,这里仅列举几个,以浅到深,大家可以选择阅读。同时,大家可以直接看开发过程中经常使用的三方库,找到其源码,以及一些源码剖析的文章结合着看,会比较高效。 AFNetworking:iOS平台有名的网络框架 GPUImage:移动端有名的链式GPU渲染框架 YYCache:iOS平台上的多级高性能缓存 ARKit:Apple的ARKit框架 CppUnit:C++的单元测试框架,短小精干的框架,体现了很多代码设计的思考 Mediapipe:Google的跨平台机器学习框架 Git:可以看Linus撸出来的第一个版本 GamePlay3D:跨平台的小型游戏引擎,但五脏俱全 书 再来推荐几本和架构有关的经典书籍: 《设计模式》:四人帮关于设计模式的鼻祖,不过太干以致于看起来有点枯燥,可作为手册,结合下面的HeadFirst一起看 《HeadFirst设计模式》:更加浅显易懂,推荐这本 《重构》:给了大量的范例,手把手教你如何进行代码重构 《代码整洁之道》和《架构整洁之道》:关于追求极致代码和架构的两本书,后者的推荐序是刚刚离开的左耳朵耗子 《代码大全》:和架构关系不大,但逢人必推,它是从学校迈入职场必读的一本书

2024/3/6
articleCard.readMore

浅谈工程师成长——关于成长的三个小故事

Stay Hungry, Stay Foolish —— Steve Jobs 成长是一个长久的话题,尤其是我们身处科技爆炸的信息时代。吴军在《浪潮之巅》中说: 科技的发展不是均匀的,而是以浪潮的形式出现。每一个人都应该看清楚浪潮,赶上浪潮,如此,便不枉此生。 —— 吴军 我们也同样处于这样一股浪潮之中,这是最好的时代,过去几十年中的技术变革超过了数百年,我们每天都有可能做着改变世界的事情,一行代码可能影响数以千万的用户。这也是最坏的时代,面对着技术的爆炸式更新,技术以比以往任何一个时候都快的速度被淘汰,每个人都面临着巨大的压力,焦虑时刻伴随着我们。 诞生于移动互联网时代的字节是一家年轻的公司,它有着年轻的产品线,年轻有朝气的员工。虽然一切都很年轻,但其业务正处于短视频这股浪潮之中,机遇与挑战并存。 最近团队有很多新加入的校招/实习生,大部分同学都是客户端零基础,在学校没有任何客户端的开发经验。公司相较于学校的差异很大,如何能够快速适应给新人们带来了很大的压力。 同时还有毕业不久的同学在经过了一段时间的职场历练之后,逐渐适应了工作的节奏,但工作中需求接着需求,很多时候没有提升的时间。似乎一个月掌握的知识就足以应对接下来很多年的工作,未来的成长在哪里?在学校面临就业这单一目标时,可以按部就班向前冲,目标清晰明确。但毕业之后我们面临着无数的选择,失去了帮我们铺好道路的学校,现在的每条道路都需要我们自己选择、面对。如何能够做到既低头脚踏实地,又抬头仰望星空? 本文便想聊一聊有关工程师成长的话题。 目录 成长中的那些故事 主动 停止抱怨,行动起来 独立思考 写在最后 One More Thing Stay Hungry Stay Foolish 推荐一些书 成长中的那些故事 刚开始在准备本文时,想讲的特别多,恨不得把过去自己遇到的所有问题,踩过的坑都汇聚一起,一股脑灌输给大家。然而很快就放弃了这一想法,一方面这不现实,如果要写下来篇幅将长到无法想象。另一方面,从小到大我们已经经历了那么多填鸭式的教育,效果怎么样?凭什么认为这次会有好的效果? 本文也不想深入讨论技术,毕竟鱼那么多,如何能穷尽?并且对于已经久经沙场的工程师来说,掌握一门技术本身并不是很困难。 记得很多年前看过乔布斯的一个演讲,那是在斯坦福大学的毕业典礼上,他并没有讲述如何创造伟大的公司,如何做伟大的产品,而是讲了三个小故事,故事中的几句话我记忆犹新。作为一名普通的工程师,我想以同样的方式,借着三个小故事来讨论成长。 主动 我毕业后的第一份工作也是一家互联网公司,从老家南下来到深圳,经历了整晚火车的轰隆声后,乘坐1号线从大冲站出来,被眼前的魔幻的景象所震惊,一边是被拆的破乱不堪的工地(后来才知道那片工地是多年之后价值千金的地段),另一边是高楼林立的现代都市。虽说去过大城市,但第一次看到深圳的大楼时,依然激动的无法言语,不是因为高楼本身,而是那些嵌在大楼上的一个个logo:联想、康佳、中兴、迈瑞、甲骨文。直到现在我还清晰的记得当时天空的白云很低很厚,仿佛触手可及,不像中部地区的天空那样高远。我如同从山洞中钻出的原始人一般,看到电视中的企业真实的展现在眼前,激动的说不出话,心想,这就是我想要来的地方。 那时移动互联网刚刚开始,诺基亚还占据着市场统治地位,四大操作系统像后来色彩斑斓的共享单车一样争夺着市场:Symbian、iOS、Android和Windows Mobile,那时所在的部门就有了一个极具野心的目标,一个叫做UI Engine,支持四个操作系统的跨平台方案,我想那是后面很多年各种跨平台方案的鼻祖了吧。 加入公司后,成天和老司机们私混在一起,熬夜发版、午休时玩dota,因为底子薄,那时可能是成长最快的一段时间。记得入职培训时一个技术大牛介绍调试方法,说日志比debug好用,当时我不明所以,问道,怎么打日志?大牛估计被我突然问的发懵,答:在想打的地方打。可想而知,我依然没听懂。 正是这一年的时间,我从学校完成的职场的初步转换。也正是那时,我从mentor那里听到职业生涯中的第一个影响至今的建议,新人最重要的两个字:主动。 没错,不是什么大秘密,但大道至简,不是么。但在当时年少轻狂的我听来,心想这算哪门子建议,说了等于没说。然而知道和做到却完全不同,直到多年以后,再回过头看走过的路,才发现这竟是工作后最重要的两个字。 作为工程师,我们可以主动去做的事情有很多,以需求开发周期来说,需求评审阶段主动了解需求收益,与产品经理讨论需求如何做更好,了解其背后的逻辑;开发阶段主动去理解三方库、底层原理,主动使用更“好”的架构设计,考虑扩展性,追求代码洁癖;需求完成后主动关注需求上线情况,主动总结沉淀,了解竞品。你看,同样都是做需求,主动做需求与被动接受需求在事情上有多么大的区别。从成长来看,短期可能没有明显差异,但一旦拉到更长时间范围来看,两者的差距会越来越大。这也是为什么毕业生刚开始起点一样,但随后几年成长速度呈现的差异巨大,主动性在这里面几乎起到了决定性的作用。 主动实际上是一种态度,驱动这种态度的是更为内在的东西,比如好奇心、对优雅代码的追求、对成长的渴望等,进一步抽象是真、善、美🐶。 为了追求“好”代码,计算机领域衍生出了很多理论,比如《重构》中的大量重构方法,四人帮提出的23种《设计模式》等(但想要记住这么多工具的确很困难,我印象最深的是“坏味道”,锻炼自己的“嗅觉”,找到代码中的“坏味道”,然后基于对“好”代码的追求去做重构),这些追求驱动着工程师们的主动性,推动个人的成长和科技的发展。 正如乔布斯所说:Stay Hungry, Stay Foolish! 停止抱怨,行动起来 30岁之前你培养习惯,30岁之后习惯成就你 —— 出处未知 我在第一家公司仅待了一年,它曾是一家辉煌的公司,但我们常调侃自己的工作方式很小作坊,因为那时刚好处于移动互联网的最初阶段,整个行业并没有成熟可借鉴的地方,大家都在摸着石头过河。而非科班出身的我总是充满不自信,似乎对规范有着一种很强的执拗。有段时间每天都深夜才能回家,记得有一天打车到楼下之后,深夜街头空无一人,不经意抬头看到月亮,想起之前住一块的同学说一周没见过我,我忽然觉得该换个地方了。于是有了第一次跳槽。 这次选择是从互联网跳到了外企,因为那份执拗,想看看成熟行业的软件开发是什么样。于是每天过起了朝九晚五的生活,由于业务的成熟稳定,一年的代码甚至不如以前一个月,之前也听过微软工程师平均一年的代码量是1000行,可能是真的😂。虽说如此,在这里我有了毕业以来最大的收获,并听到对我影响至今的第二句话。 当时研发部门经理是一位上了年纪的德国人(德国人从外表很难看出年纪),他像往常一样召开了一次部门会议,会议的内容我早已忘记,但他在会上说的一句话让我铭记于心: Never complaint. Turn complaint to a proposal. Turn proposal to an action. 会后我将它记录在Evernote上,笔记创建的日期显示是2012/5/4。 抱怨是创新的来源,每一个抱怨的背后都是机遇,每一个能将抱怨转化为行动的人更有机会取得成功,大到国家政权,小到产品、工具和SDK。乔布斯因为不满随身听/CD的臃肿与容量而创造了iPod,迅速带领苹果走出泥潭。因为不满功能机而发明了iPhone,开启了智能手机时代,将苹果带到全球市值最高的公司;马云因为不满淘宝交易的安全性所以有了支付宝;张小龙因为不满移动端的社交体验,所以有了微信,让腾讯拿到了移动互联网时代最重要的船票;Thiago de Arruda在提交多次MR被拒后,因为不满Vim的研发效率和兼容,所以有了NeoVim,而这也促使Vim更新速度大幅提升;软件开发者因为效率的低下而创造了无数的工具、SDK。这样的例子不一而足。他们有一个共性,不抱怨,采取行动。 在此之前我经常抱怨需求太多没有时间学习,抱怨在学校没有学过网络、数据库、操作系统,抱怨没有机会进行技术交流等,但抱怨之后没有任何行动,陷入抱怨 -> 不作为 -> 抱怨的恶性循环。也正在是听到这句话之后,我才尝试打破这个循环,开始花大量时间恶补计算机基础,那段时间将计算机领域的经典书籍几乎看了个遍。为了有机会沉淀,也搭建个人博客,开始尝试写高质量的技术文章。 有人会说学那么多东西根本不知道什么时候会用到,但如同乔布斯在演讲中说的collecting the dots一样,你无法提前预料将来,只需求静静收集,在未来的某一刻,过去所学将会派上大用场。对我来说一个深刻的例子便是这篇在支付宝追查闪退的六个日与夜,这个闪退问题用到了汇编、编译、链接、信号、debug工具等等知识,如果没有过去那些看似派不上用场的积累,恐怕耗费的时间不知道要花多久了。 停止抱怨,行动起来。 独立思考 最后一个话题想聊一聊独立思考。 在学校时我们有明确的目标,中学是为了考大学,大学是为了就业,有学校、老师、师兄师姐早已帮我们定好了目标,虽然道路崎岖,但目标明确。什么应该做,什么不应该做很清晰,答案已经被无数人验证过。我们要做的只是努力,按部就班即可。可工作之后一切都变了。 我们从小是在各种习题海洋中成长的,客观题只有唯一答案,机器都可以阅卷。除客观题外,还有所谓的主观题,如思考题、阅读理解,但它们其实并不主观,因为主观意味着一千个读者眼中有一千个哈姆雷特,但我们的世界中,主观题只有一个标准答案。所以我们养成了看到问题时就会去想它的正确答案是什么的习惯,在做题之前就想着它有没有参考答案,做完之后如果没有参考答案我们甚至会觉得无法进行下去。但毕业后才逐渐发现,不管是工作,还是生活,根本没有人给你参考,更没有答案,只有结果,所有的结果只有靠时间才能得到验证,也许是一天,也许是一年,也许是一辈子。在习惯了有标准答案之后,我们的生活和工作仿佛失去了前进的勇气,只能被动接受。 我很喜欢看NBA,记得有段时间每场球结束后,我都会去对应球星的贴吧里面看贴子,其实是想看大家的“答案”,大家如何看待这场比赛,是否有和我意见一致的。对于看书、电影也一样,喜欢读完之后去看影评、书评,看别人是什么想法,似乎别人能够给我答案。工作后逐渐不敢发表自己的看法,大家趋于思想统一,在群体思想面前不敢表达自我,独立思考能力在逐渐丧失。 在2013年的某一天,有个朋友来家里玩,忘记具体是聊到什么话题,我突然意识到自己独立思考能力在逐渐丧失,做事全凭感觉,这对我的工作和生活产生了巨大的影响。于是跑去图书馆开始疯狂寻找有关思考的书,《思考,快与慢》、《清醒思考的艺术》、《思考的技术》、《李天命的思考艺术》、《思考的力量》、《系统思考》、《超越感觉:批判性思考指南》等等,这两个字就像是救命稻草一样被我牢牢抓住,凡是标题中有思考两个字的书都被借了回来。 然而就像思考能力的丧失不是一瞬间造成的一样,重新拾回思考能力也并非一朝一夕可以完成。如《思考,快与慢》中的研究,大脑默认“懒惰”,能靠直觉绝对不会动脑,所以独立思考很反人性,因此思考是一种需要一直践行、长期坚持的习惯。但它会让你面对纷繁的世界时不盲从,让你在面对复杂问题时迅速抓住事物本质,让你回到不再被标准答案所束缚的世界。 技术人的思维很简单,有时候我们常常满足于这种简单,觉得很专注,我们会将偶像定为Facebook创始人扎克伯格、Linux作者Linus Torvalds、Google创始人Larry Page、字节跳动创始人张一鸣,他们都是技术人的典范。但专注并不意味着封闭自我,并不是两耳不闻窗外事,一心只读圣贤书。查理芒格说过: 认识并适应你周围世界的真实本质,不要指望世界会适应你。不断挑战和主动地修正你“最爱的观念”。认清现实,即使你不喜欢现实——尤其是你不喜欢它的时候。 做一个实际的人,不要活在自己的想象世界里,这句话也送给我自己。 写在最后 在一次离职时和一位大老板聊了一会,他讲述了自己过去如何去做职业选择,原话已经记不清,大意如下,供参考。其实和《浪潮之巅》里讲述公司的一样,寻找那股浪潮,选择最有活力的行业,选择好的公司,业务和团队,随着业务一起成长。永远不要单纯的为了职位和钱去做选择。 成长是持续性的,更是跳跃性的,它是在某一刻因为一个人、一件事或一句话在心里播下了一颗种子,接着抑或迅速抑或缓慢的生根发芽。当你回首过去,你会想起,是的,就是那一瞬间你开始有了一个想法,佛家谓之顿悟。我很喜欢的一部电影《盗梦空间》将其具象化了,改变一个人的想法只需要在他头脑中埋下一个种子,让它自己成长,最后它会毁灭整个帝国。如果多年以后,你说我就是在听君一席话之后在心中埋下了那颗成长的种子,那便是此文最大的成功。 世上的鸡汤、成功学已太多。古人云,读万卷书,行万里路。路最终都需要靠自己走,成长是个人的事情。每个人都有自己对成长的理解,以上仅是我的一些浅显认识。所谓知易行难,即使是上面的三件事我也不能完全做到,但成长一直在路上。 最后,以我喜欢的一个slogan结束此文,与大家共勉:Just do it! (全文完) feihu 2022.06.27 于 Shenzhen One More Thing Stay Hungry Stay Foolish 乔布斯斯坦福大学2005年毕业演讲,缅怀乔帮主 推荐一些书 不能免俗,推荐几本读过的,并对我有着巨大影响的书吧。 技术 《代码大全》:现实软件开发世界的百科全书 《深入理解计算机系统》:将它称为技术书籍中的九阳神功毫不为过 《编码的奥秘》:如果大学老师这样教学,中国的计算机行业会有更多的人才吧 行业&视野 《浪潮之巅》:了解科技行业最好的书籍,技术人文笔的巅峰 《黑客与画家》:stackoverflow创始人,他不仅创办了程序员最喜欢的网站,还对互联网有着先知一般的认知 思考 《高效能人士的7个习惯》:亚里士多德说“卓越不是单一的举动促成的,而是由习惯决定”,而这本书便讲的便是习惯 《财富自由之路》:李笑来不仅只有币圈和新东方,这本书会刷新你的认知,网上各种有关认知觉醒类书/公众号的鼻祖 《金字塔原理》:如何让表达更有条理

2022/6/25
articleCard.readMore

追查闪退的六个日与夜

老Y是一名iOS开发,近日在工作遇到一个闪退,临近提交App Store时才发现,虽然紧急修复后顺利提交审核,但由于问题暴露的时间过晚,因此引起了众多额外的关注。问题本身理应很容易发现,但为什么临近审核才反馈,老Y为了分析该问题,花了六天的时间才找到原因。在一个具有数百个模块的大型App中,如何根据各种蛛丝马迹找出问题根因?本文记录了老Y追查该问题的六个日与夜。 闪退不可怕,开发与测试的过程都应该能够发现,这个问题本身没有特别值得记录的地方。可怕的是为什么经过这么多轮测试却没能发现闪退,开发联调阶段也没能发现闪退,正是为了解释这些才有了本文。 如果你的App中也使用了PLCrashReporter,或者你对老Y追寻六天的过程感兴趣,不妨看完这个故事。如果想直接看结论,可跳至后记。 目录 第0天 阻审 第1天 嫌疑 第2天 堆栈 第3天 信号 第4天 提速 第5天 浮现 第6天 真相 后记 第0天 阻审 晚上11点,老Y刚洗完澡准备睡觉之际,看了一眼手机,发现被拉进一个群,群名称:”xxx crash”,心想:“我去,今晚就要提交App Store审核,该不会这个点出现问题了吧。”老Y点进去一看消息,不幸言中。业务发现一个必现的闪退,其它同学已经可以在本地复现,正在分析问题产生的原因,PM在等着结论,决定何时提交审核。 这样的情形对于老Y来说已经不会再大惊小怪,多年的互联网从业经历让老Y养成了电脑随时带在身边的习惯。12月的深圳此时终于有了点冬天的样子,夜里气温很低。于是老Y披上外套,拿着电脑来到客厅。 打开电脑后,老Y首先看了看群里的上下文,目前了解到的情况是:业务每天都有进行测试,但之前一直未发现问题。而今晚却在另外一个安装包中发现该问题是必现的。“之前的安装包与今晚测试的安装包有什么不同?”,老Y开始与反馈该问题的同学一起分析。刚开始推断可能前几天有几个模块进过一次集成,但很快这个猜想就被推翻。测试发现集成前后的安装包都会出现该闪退。 测试在开发的询问下,不断的提供越来越多的信息,完整的问题逐渐显现出来。终于,在午夜钟声敲响之际,定位到是debug包与release包的问题。之前测试使用的都是debug包在测试,而今晚切换到了release包。“又是release与debug的问题,一定是有什么变量没有初始化导致的,release下未初始变量可能是个随机值”,凭借之前踩过的很多坑,老Y信心满满的猜想。 而此时,负责该模块的同学老J本地调试也发现了该问题,准确的定位到闪退处。从Xcode的debug窗口中可以明显的看到一个空的数组指针,访问该数组时出现EXC_BAD_ACCESS闪退。那么问题看起来很容易解释,只需要查出为什么该数组是空指针就能够解决问题,笑容在老Y与老J脸上浮现出来。 可是,他们高兴的太早。这个数组指针在一个函数中赋值,但该函数没有异常路径,数组一定不会为空。“这就奇怪了,这个函数出了什么问题?”,老Y与屏幕另一端的老J都感到不解。为了弄清楚函数执行时发生了什么事,老J在该函数中设置了一个断点,打算单步调试该函数,这是最直接的方式。 没想到奇怪的一幕再次发生,老J刚刚设置的断点竟然不生效。老J不敢相信自己的眼睛,于是又在函数调用前与调用后又加了断点,再次运行。函数前的断点正常停下,“不错,证明代码走到了这里”,老J开始紧张起来,小心的点了单步执行,没想到程序直接到了函数调用之后的断点,函数中设置的断点依然没有停下来。 “这不可能啊,同一个代码库中的代码,函数前后的断点都能够停下,为什么这个函数就是进不去?”他们大为不解。此时,老Y说,用lldb命令试试吧。于是老J再次调试,在调试控制台中输入了breakpoint set -name xxx,再次运行,他们紧张的期待着运行的结果。 老Y说:有时候在Xcode的源代码中直接添加断点无法进入时,可以通过lldb或者添加符号断点的方式来设置断点 终于,如他们所愿,程序在函数入口的断点处停了下来,然而展现在断点处的竟然是汇编。“源文件明明就在工程里面,为什么会进到汇编?”,老J说。“clean下试试。”于是老J将整个工程清理了一遍,为以防万一,又手动删除了Derived Data,然后再次尝试。可结果依然如此。 “直接用代码编译的,又是同一个工程下,函数前后可以断在代码处,所以排除了静态库的影响。”老Y分析道,“难道?。。。”老Y此时心里冒出了一个大胆的想法,于是他和老J说,“VNC地址发我下,我VNC过去看看”。随后老Y连上了老J的电脑,打开了命令行,在工程目录中敲了一个命令,开始对所有的静态库查找断点函数的符号。当命令运行结束时,大家看到结果后心里似乎明白了老Y这么做的原因。原来,这个函数在另外一个静态库F中也存在一份。听到这个消息后,写下这个函数的另外一名同学老C想到了什么,突然说,“这个函数是是我从一个静态库中拷贝过来的,并且更新了代码。” 这时,老Y与老J都明白了问题所在,他们讨论道:“静态库X与F中有两个同样的符号,但是似乎真正链接到二进制中只保留了一个”,老J补充道,“是的,所以业务在运行时,本应该执行静态库X的代码,实际上却执行了静态库F,后者没有对数组指针赋值,便导致了闪退的出现”。 “我们要快速解决这个问题,PM还等着提交App Store呢”,老J说,“把函数名都改一下吧。” “可是相似的代码还有挺多,一个个去修改风险比较高,直接加上namespace作隔离吧。”老Y提议,于是他们快速给相似的符号加上了新的namespace,再次本地打包验证,问题解决,此时时间刚过午夜1点。 本地验证通过之后,他们立刻在服务端打包,发给测试及业务同学验证。从问题出现到解决只花了一个多小时,如果不是因为debug无法进入断点,根本不可能这么快。在等待测试同学反馈给果时,老J问老Y,“怎么会想到去运行那个命令去查找所有静态库中的符号?”“其实也是比较偶然,已经是同一份代码进行编译,符号一定是同时被加载的,一些函数可以进入断点,另外一些无法进入,这有点说不通。我在前一家公司遇到过类似的问题”,老Y回忆起了往事,“但那时是动态库,两个库中也有相同的符号,当时现象并不是必现的闪退,而是概率性的出现。后来分析了很久才找到原因,结果是一个叫做 Global Symbol Interpose的问题,最终也是通过不同的namespace才解决了问题。” 老Y说:全局符号介入(Global Symbol Interpose)是在动态链接加载的过程中出现的,Linux采用广度优先搜索的方式来加载每个库中的符号,它会将每个符号都放到一个全局的符号表中,如果表中已经有了这个符号,那么它会忽略后面需要加载的符号 “但是为什么测试验证时,debug包不会出现闪退呢,按理说这是必现的问题”,老J问,“我推测是debug与release在链接两个库时的顺序不一致导致的,debug与release分别链接了不同的符号,导致debug是期望的符号,release是另外一个静态库的符号。但为什么这样我也还不清楚,现在也只是猜测”,老Y解释道。 正当他们讨论之际,测试同学反馈新包问题已解决。此时已经是夜里2点,PM还在等待着他们的结论,看到老Y他们在群里的结论之后,PM给老Y打了电话,老Y说明了结论和问题之后,PM问道,“为什么这个时候才发现?现在阻塞了提审,今晚肯定无法提交了,明天再提交吧。这个问题需要做一个复盘。”老Y想解释,想说这一定不是我们的问题,但无奈现在也只是推测,而且确实阻塞了提审,也没有作更多的辩解。挂了电话之后,老Y有些气愤,虽然他理解PM面临的压力,可是还没搞清楚状况就劈头盖脸一顿责问,这让老Y有些难以接受。 “我们先申请模块集成吧,为什么debug包无法出现闪退的问题明天继续分析”,集成申请之后,他们便合上了电脑,兴奋中夹杂着疲惫,沉沉的睡去。 老J和老Y快速解决了阻塞提审的问题,但似乎这个问题本身并不是很严重,更加严重的是为什么一个本该必现的问题这么晚才验证出来。老Y隐隐感到这里面有一个巨坑,也因为PM的一通电话,他下定决心要查明该问题,还团队一个清白。但没想到的是,这一查就是足足六天,接下来的六天时间里,老Y一步步找出了问题,原来那是一个隐藏了多年的bug。 第1天 嫌疑 第二天老Y早早醒来,虽然前一天折腾到很晚才睡,但精神还不错。在去公司的路上,他就向负责构建的同学L描述了这个问题。L是打包这块的专家,他对打包过程极其了解。 互联网公司的上班时间相比传统公司较晚,9点公司楼下人依然很少。老Y在公司吃完早餐后,泡了一杯龙井茶振作精神,到了这个年纪,大家的标配似乎已经是枸杞菊花保温杯,但老Y还是喜欢喝点茶。一口热茶暖到胃里,老Y心想,“开始吧”。于是他打开了电脑,准备投入工作。 电脑是一台几年前的Macbook Pro,已经服役三年,很快就要退休。近段时间电脑越来越无法跟上老Y的使用强度,当然,也可能是跟不上软件的更新速度,隔三叉五的就会罢工一次。 对于前一天的问题,老Y已经有一个初步的推断,“debug与release包链接了不同的符号”,但为什么会这样,还需要等待构建专家L的分析。L收到了早上的消息后,与老Y通了电话,对问题进行了详细的询问,了解到问题之后,L心里也犯起了嘀咕,“这个问题还是得详细的查下,万一是打包平台的锅,影响可就大了。”于是他们先兵分两路,老Y准备下午的复盘文档,测试同学定了下午2点的复盘会议。而L去分析打包的问题。 老Y这么多年一直有个工作习惯,在遇到复杂问题时,会创建一个笔记,用于记录对问题的分析及进展。一方面有助于理清思路,防止被其它问题中断后无法迅速找到之前的状态。另一方面通过详细的记录,又可以后续对问题进行总结,遇到相似问题时能够通过这些记录找到当时详细的分析过程。于是他新建了一个笔记,取名:“XXX阻审问题分析”,笔记创建完成后,他首先记录了当前的问题与进展,接着开始准备复盘的文档,很快就将问题、解决方案、后续如何避免等写完,但其中有一个点还没有结论,也就是为什么提审前才发现问题,这个点老Y觉得可能问题没那么简单。 果然,在复盘文档刚刚写完时,L来了消息,印证了老Y的担忧。L发现不管是debug包还是release包,都是一样的符号,符号来自静态库F而不是静态库X。“如此说来,前面的推断都错了,并不是链接了不同的符号导致现象不同”,老Y说,然后接着问,“两个包在链接静态库的时候,依赖的顺序是一样的对吗?”L回答,“是的,顺序是固定的,和debug、release无关。” “明白了,这样就不存在一个静态库的链接顺序导致不同符号被链接的问题”,老Y分析说,“但这样解释不通,如果是同样的符号,应该debug包也有同样的问题,而不仅是release包才出现闪退。” “debug和release包除了宏及优化level不同以外,还有其它的区别吗?”老Y问L。 “有的,debug包里面多了一些测试模块,一会我做一些实验验证下是否是这些测试模块的问题”,L回答。接下来他便与测试同学一起验证有关测试模块的猜想。 老Y在等待L分析具体依赖差别的时候,又想了想,“昨天业务测试的反馈信息有些多,需要重新梳理一遍,究竟什么样的包出现,什么样的包不出现,先整理一个详细的列表出来。然后看看问题可能出现在哪个包中”,于是他将昨天修复问题前后各个场景都打了一个安装包并测试,结果发现只有在release包中才会闪退,其它所有安装包都正常。 “空指针访问时闪退是必然的,这样就可以集中精力定位为什么debug包不闪退,现在关键的问题在于找出空指针访问后,本该闪退的线程现在在做什么。先在本地尝试保持与debug包一样的版本依赖,看本地是否可以复现该问题。”于是老Y找到对应debug包依赖的各模块版本,然后搭建本地环境,开始调试。但让他没想到的是,本地不管是debug还是release,都是必现的闪退,程序停留在空指针访问的那一行,EXC_BAD_ACCESS异常。 “奇怪,本地打包与后台打包怎么会表现不一样,难道后台还有很多其它的设置?”老Y很困惑。“既然本地无法复现这个问题,那只有增加一些日志,查看这个线程在空指针访问的前后在做什么”,老Y便在前前后后增加了大量的日志,期望这些日志可以告诉他发生了什么事。由于本地无法复现,所以他只好将代码提交,并依赖后台打出来的包才能够获得日志。提交代码之后,便安心等待后台的安装包构建。因为安装包依赖了数百个模块,所以每次的等待都需要接近十分钟的时间。在这期间,老Y又问了L那边的进展,L发现debug与release总共有6个测试模块不同,而去除了这些测试模块的debug包让测试同学去验证时,如期望的那样闪退了。“也就是说。。。”老Y激动的说,“没错,也就是这六个测试模块导致了本该出现的闪退被吞掉”,L回答,但L有一点忘了说,有一次测试时发现没有测试模块的debug包也未出现闪退。 “帮忙把这6个模块的名字发出来,我打算找出它们的负责人,然后拉个群问问看它们有没有在这些测试模块中catch住这个闪退。”老Y激动的说,L便去查找每个模块的负责人。正当此时,老Y增加日志的安装包已经构建完成,他下载并安装了这个包,然后熟练的在业务环境去验证。果然,这个包依然没有闪退。接着他获取了程序运行的日志并开始分析。 现象很奇怪,日志中有刚刚老Y增加的部分,在野指针访问之前,一切都正常,并且也证实了他们之前的猜想,代码走到了另外一个静态库F中,而这个旧的代码并没有初始化新增加的成员变量,这也是导致野指针的原因。可是,时间在这个线程仿佛停止了,野指针访问之后的日志都没有出现。同时,接下来的所有日志中再也没有出现该线程的其它任何活动。“奇怪,看起来像是这个线程在访问了野指针之后被干掉了一样,会发生这种事情吗?”,老Y疑惑的想,“难道是那几个测试模块把闪退捕获了,但没有处理好?” 这时,L拿到了这6个模块的名称,并找到了对应负责的同学。老Y将他们拉到一个新建的群中,然后描述了所遇到的问题:“各位测试模块的owner们,我们这边遇到一个棘手的问题,一个C++的必现闪退在debug包中不会出现,但在release包中会出现,对比发现debug包里面多了各位的测试模块。在必现闪退的地方,打了日志发现在闪退前的位置有日志,闪退后的位置没有日志,且整个线程也看起来被干掉了,所以想问下各位,有没有哪位的模块把C++的野指针访问异常catch住了。”在漫长的等待之后,有几个模块的负责人表示其负责的模块中并没有捕获闪退的代码。 老Y也继续尝试增加日志,发现不管是空指针,还是野指针,都一样不会闪退。刚开始他还怀疑是野指针是不是有可能访问到一个有效的地址而没有闪退,空指针才会闪退,但打印出来的日志粉碎了他这一猜想。时间就在他们不停的尝试与分析当中迅速流逝,夜幕很快就降临,在与团队同学一起吃饭时,他描述了问题以及当前的进展,大家也都表示这个问题很诡异,没有遇到过类似的问题。 当天是周五,看这天的进度,问题原因恐怕很难被快速定位出来,所以复盘也从2点推迟到4点,虽然最终阻塞提审的问题早就解决了,但是背后影响更大的问题还没有思路,如果这个问题不分析出来,复盘意义不大。所以老Y与测试同学讨论将复盘改在下周进行。 老Y所在的公司地理位置较好,楼下有个海边公园,每次吃完晚饭,大家便不约而同的绕着海边散步,缓解一天的疲劳。但今天老Y却没有和大家一起去,他打算尽量在周末之前搞定,这样便不用在周末还挂念此事,于是吃完晚饭之后,老Y立刻回到了办公位,他还是想着在本地复现这个问题,定位出具体哪个测试模块是罪魁祸首。他又对比本地与后台安装包的模块与版本是否完全一致,以防止出现遗漏。经过仔细对比,发现确实有几个模块在本地没有,于是他又尝试去后台下载了这几个缺少的模块,手动链接这些库,但还是和之前一样,本地必现的闪退,EXC_BAD_ACCESS,无法在本地验证。 不知不觉折腾到11点多,老Y周末之前搞定的计划落空。很无奈,本地无法复现,只能依赖服务端。他找到L,“我们目前的结论是6个测试模块捕获了闪退异常,有没有办法能够在服务端尝试加载不同的测试模块,确定是哪个测试模块吞了这个闪退吗?”好在L也很重视这个问题,即便是周末,也答应了一定帮定位出是哪个模块,这让折腾一天疲惫的老Y心里感到一丝安慰。老Y查了一整天,但总有些力不从心的感觉,无法在本地复现问题,甚至打个日志验证都需要10分钟以上的时间,身心俱疲。老Y准备周末好好休息一下,调整好状态,周一接着查。 第2天 堆栈 深圳虽已是冬季,但仍和中部地区的初秋差不多,一件薄外套即可抵御寒冷。一年当中深圳从3月-11月都是夏季,只有此时的阳光显得温暖却不炎热,即便是正午,也可在户外活动。深圳遍布的公园草地上都铺满了大大小小的野餐垫,孩子们与爸爸妈妈享受这个快节奏城市里难得悠闲的时光,空中飘着各色各样的风筝,宛如秋游的景象,如果不看日历的话,绝难想到此时已是12月中旬。 老Y晚上睡得不好,好像做梦都在思考这个问题。早上起来之后,老Y回顾了昨天的分析过程,“这个线程后面没有了任何的日志,那么它到底在干什么?是线程被干掉了吗?还是说函数异常中止去做了其它的事情?”,“有什么办法能够获取到当前这个线程的状态?”,这几个问题一直萦绕在老Y的心里。整个上午老Y做什么事都一幅心不在焉的样子。午饭过后,突然间老Y有了灵感,“是否能用什么手段让整个应用崩掉,从而获取所有线程的堆栈?” 顺着这个思路,老Y想到一个办法。此刻也顾不得午睡,打开电脑便开始了尝试。他想在另外一个线程直接通过ObjC的方式来让程序崩掉,让一个程序崩掉的方法有很多,因为之前是C++的闪退无法处理,那么这里就通过ObjC的方式。很轻松的,老Y写了几行代码,直接创建一个ObjC对象,然后向它发送一个并不存在的方法。“这样便会出现对象无法响应方法的闪退了”,老Y自言自语道,“那么在什么时机来调用这个方法?先试试压后台吧。”老Y在一个类的load方法中注册了应用压后台的通知,并在回调中调用了触发闪退的方法。 - (void)triggerCrash { XLog(@"enter crash"); UIView *view = [[UIView alloc] initWithFrame:CGRectZero]]; [view performSelector:@selector(hello)]; XLog(@"leave crash"); } 接着提交代码,等待漫长的打包结果。打包成功后,老Y立刻安装并进行了测试,首先是测试业务流程,空指针访问处没有闪退。接着程序压入后台,如他所料,闪退出现。老Y兴奋的赶紧去服务端捞取对应的闪退报告,可奇怪的是,服务端的最新报告日期竟然还是前一天,刚刚的尝试完全没有闪退报告。“难道是OOM了?只有这种情况下才没有报告”,老Y猜想。他又试了一次,更为奇怪的是,程序在还未进入后台时就崩了,服务端依然没有闪退报告。因为老Y的注意力都在没有查到闪退报告这件事上,所以他忽略了为什么程序还未进入后台时就崩溃这个细节。 这下老Y突然有些不知所措,怪事一件接一件的发生,但好在他已经做好了没那么顺利的心理准备,冷静下来之后想,“之前即使是发生了OOM,在Xcode中应该可以直接从手机上捞出来对应的报告,这是由系统生成的,可以试着捞一下”,老Y便连上手机,在Xcode中查看设备日志。 老Y说: 系统在应用闪退之后,会生成一份闪退报告,这里甚至会包含OOM类型的报告,可以通过Xcode的设备日志获取。 在等待日志同步之后,老Y发现有两份闪退日志,但都不是OOM,而是普通的闪退。最新闪退是第二次的尝试,未压后台就崩掉,它与之前业务测试时发现的报告完全一致。挂在了空指针的访问处,闪退线程如下: Thread 54 Crashed: 0 XXX 0x0000000108ab01c0 A::run(Image&, float*, int, bool) + 99549632 (file.cpp:259) ... 老Y又打开了另外一份报告,惊喜的发现这便是他这次实验想获取的那份。闪退的原因正如代码所写,在压后台时上面的triggerCrash方法闪退,找不到对应的selector。接着他向下查找其它线程。终于,在最后一个线程,那个本该闪退的地方出现了: Thread 54 name: Dispatch queue: com.xxx.xxx Thread 54: 0 XXX 0x00000001066d01c0 A::run(Image&, float*, int, bool) + 99549632 (file.cpp:259) 1 XXX 0x00000001066d01b4 A::run(Image&, float*, int, bool) + 99549620 (file.cpp:259) ... 值得高兴的是,这个线程并没有被干掉,也没有从闪退的方法中退出,它就停在空指针访问的地方。但令人崩溃的是,堆栈中竟然有两个闪退的方法调用。“A::run的代码中并不存在嵌套调用,怎么可能堆栈中会出现两次?”,老Y再次感到不解。从两个frame的地址上看,两者相差12个字节,上面正常闪退的最后一个frame的地址与frame 0相同,都是995449632,那么下面的99549620是什么?“代码上来看,两者是同一行,那便只能从汇编上进行分析了。” 老Y打开了专业的汇编分析工具Hopper Disassembler,然后对着file.cpp:259开始分析,259行代码是: 257 for (int j = 0; j < size; j++) { 258 XLog(@"run 5.2"); 259 float x = a[0] * b.c[2 * j] + a[1] * b.c[2 * j + 1] + a[2]; 260 XLog(@"run 5.3"); 261 float y = a[3] * b.c[2 * j] + a[4] * b.c[2 * j + 1] + a[5]; 262 XLog(@"run 5.4"); 263 d[2 * j] = x / d; 264 XLog(@"run 5.5"); 265 } 接着老Y用工具加载闪退版本的.ipa包,再从闪退报告中找到偏移地址:99549632,在工具中跳转到该偏移地址处,得到汇编: 凭借增加的日志,老Y很方便便找到了C++代码与汇编之间的对应关系。可是,即使从汇编中也无法找到栈的调用关系,理解不了为何出现两个run。老Y接着在技术群里面询问了该汇编代码及闪退报告的奇怪之处,暂时也没有人从这段代码中找出来问题。 此时,L那边也来了消息,他做了很多实验,最后发现即使没有任何测试模块,debug安装包也不会闪退,也就是说闪退根本不是被测试模块吞掉了,而是另有其它原因。连续的两个暂时性的结论让老Y的分析走进了死胡同,不知不觉他已经坐在电脑前接近10个小时。唯一令老Y感到高兴的是,他知道这个线程还活着,并且停留在本该闪退的位置,同时也排除了测试模块的影响。这下他的分析可以聚焦在本该闪退的线程处。 暂时也没有其它的思路,老Y在“阻审问题分析”的笔记中记录了分析的过程及当前的进展,然后决定周日休息一天,放空大脑,周一再继续。 第3天 信号 经过周日充分的休息,老Y周一上班时感觉头脑很清醒,因为之前在笔记中做了详细的记录,他快速的回到了工作的状态,像是程序在函数调用时将调用前的状态压入堆栈,等函数调用完毕后又从堆栈中弹出一般。“突破口应该还是在这段堆栈之中,也许与闪退报告打过无数交道的框架同学可以给些建议”,于是老Y找到框架的同学老Q,描述了这个问题及现在的进展,老Q向老Y要了异常与正常闪退的报告。果然,老Y这一步走的非常正确,老Q很快便看出来了这份报告的异常之处。“这两份报告都是系统生成的,在生成的过程中可能会受到程序运行状态的影响,其寄存器等状态都不对,无法准确反应出闪退时刻的状态”,老Q继续补充道:“你需要找到应用自己生成的闪退报告。” 老Y听到这里很兴奋,终于他不用再纠着这段汇编不放,于是说:“我试过了让程序访问无效方法去获取堆栈,虽然应用闪退了,但是并没有在服务端捞到报告,只找到了这两份系统生成的报告。现在我并不需要程序真的崩掉,我只是想拿到程序当前的运行状态,知道那个线程在做什么。”老Y没有停下,继续道,“有没有可以让应用获取当前所有线程堆栈的方法?” 果然找对了人,老Q提供了一个API,可以像闪退时程序获取闪退报告一样去获取当前所有线程的运行状态,老Y赶紧将这一API加在了测试代码中。将之前的triggerCrash方法换成了这个API去获取报告,并将报告打印在日志中。10分钟后,老Y在打好的包中再次尝试,日志中的堆栈出现在老Y眼前的那一刻,他长舒一口气,果然之前的两个run不正常,堆栈与release包闪退堆栈一致,只有一个run,“这就可以解释为什么空指针访问之后的日志都没有打印出来,线程完全停在这一行,没能向下执行”,可是除了PC错误以外,老Y暂时想不出还有什么办法能让线程停下。 老Y又盯着报告看了很久,突然注意到报告的最上面,这种类型的异常都是段错误SIGSEGV,非法地址访问。突然老Y想起之前怀疑测试模块时的情形,当时怀疑某个测试模块拦截了闪退,就像在程序遇到闪退时,有时间来生成闪退报告一样。 Exception Type: SIGSEGV Exception Codes: SEGV_MAPERR at 0x0 Crashed Thread: 54 “是否可能是哪个模块处理了SIGSEGV,但是没有处理好导致的?”老Y猜想,“先在demo中试一下,如果这个信号被注册,但是不处理,是否会有一样的现象?”,于是老Y构建了一个这样的demo,给SIGSEGV设置一个仅打印日志的空handler,观察会发生什么: // ViewController.m static void handler(int sig, siginfo_t *dont_care, void *dont_care_either) { NSLog(@"sigsegv"); } // viewDidLoad // ... struct sigaction sa; memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); sa.sa_flags = SA_NODEFER; sa.sa_sigaction = handler; sigaction(SIGSEGV, &sa, NULL); NSLog(@"current SIGSEGV handler address: %p", sa.sa_sigaction); struct sigaction oldact; sigaction(SIGSEGV, NULL, &oldact); NSLog(@"current SIGSEGV handler address: %p", oldact.sa_sigaction); NSLog(@"before sigsegv"); int *a = NULL; *a = 0; NSLog(@"after sigsegv"); // ... 但当程序运行起来的那一刻,老Y再次懵掉,Xcode直接停留在于了*a = 0那里,EXC_BAD_ACCESS错误,虽然日志上可以看出设置了handler,但并没有进入到handler的回调中。“奇怪,明明注册了signal,不可能不回调吧,难道是不能debug?”,老Y抱着试试的想法,直接点开了demo,奇迹发生了,程序竟然没闪退。这可能是分析这个问题几天时间以来,老Y最为震惊,也收获最大的一个意外。他赶紧打开了控制台,去抓取当前demo的日志,发现不停的打印着sigsegv的日志,而且程序并未崩溃。 为了获取handler处理之前的日志,老Y杀了进程,又重新进入demo,闪退之前的日志出现在了控制台中,随后不停的打印sigsegv,闪退之后的after sigsegv一直没有出现。老Y兴奋的想,“这不就和我们遇到的情况一样吗?这个线程显然没有向下执行,一直在打印handler的日志,但主程序还活着”。此时,老Y获取到了handler的地址,“能否通过这个地址找到对应的符号名?”老Y想,Hopper是可以通过地址获取到符号名,但当前的地址是程序加载后符号的地址,包含了二进制本身的偏移,比如任意一个闪退报告: symbol address image address offset | | | 0 libsystem_kernel.dylib 0x00000001b2c74dd0 0x1b2c71000 + 15824 堆栈中有三个地址,分别是: symbol address: 程序运行时符号的地址 image address: 对应image加载的首地址 offset: 符号相对于image的偏移地址 而老Y在日志中获取到的是symbol address,还需要拿到image address才能够分析出最终的符号。如何才能够获取到image address?老Y已经驾轻就熟,在demo中又构建了一个无响应方法闪退,通过Xcode拿到了设备的闪退日志,这其中就有image address,很容易就可以计算出offset的值,并在Hopper找到符号的名称就是handler。但每次Hopper处理的速度太慢,老Y有点不耐烦,换了更快的命令: atos -arch arm64 -o demo.app.dSYM/Contents/Resources/DWARF/demo -l <image address> <symbol address> “通过同样的方法可以在App中获取闪退之前注册的所有信号的handler”,老Y似乎看到了希望,因为每实验一次的耗费的时间太长,所以他仔细的设计了这个实验的方案,确保可以一次尝试得到所需要的信息: 首先在程序启动之后打印一次,获取初始的handler 刚进入业务打印,获取当前handler,防止被某个业务修改 正常执行业务场景 执行完成后打印handler,确认handler是否改变 因为是在压后台的时刻调用获取handler方法,所以共总会压后台三次。修改完demo,老Y提交了代码并开始打包,然后在心里记录好操作的步骤,分析是否还有遗漏之处。经过了焦急的等待,服务端终于构建好安装包,老Y赶紧下载并进行测试。他小心翼翼的按照上面设计好的步骤来操作,一切都如他所期望的那样,程序依然没有崩溃。在最后一次压完后之后,他将手机放在了一旁,打开MacVim开始分析日志。 所有的信号都被打印了出来,但有三点出乎他的意料之外,首先每一次压后台的操作获取到的handler都完全一样,这说明在他选择的这几个时机,程序的handler都没有改变。 第二个奇怪之处是很多信号的handler地址是0x1。老Y仔细查看了文档,发现原来它是和后面的flags一起使用,代表SIG_IGN,意味着忽略该信号,通过signal(SIGHUP, SIG_IGN);API设置。 第三个奇怪的地方是只有SIGABRT注册了handler,SIGSEGV并没有handler。老Y又查了文档,了解到SIGABRT是在程序调用abort时才会抛出的信号。但空指针的访问一定是SIGSEGV,前面的堆栈报告中也证实了这一结论。“难道SIGABRT也能够处理非法地址访问的信号吗?”老Y有点不敢相信。 SIGHUP handler address: 0x1, mask:0, flags:2 SIGINT handler address: 0x1, mask:0, flags:2 SIGQUIT handler address: 0x1, mask:0, flags:2 SIGILL handler address: 0x0, mask:0, flags:0 SIGTRAP handler address: 0x0, mask:0, flags:0 SIGABRT handler address: 0x1013597ac, mask:0, flags:65 SIGSEGV handler address: 0x0, mask:0, flags:0 ... “先看看SIGABRT的handler是哪个模块注册的吧”,老Y想,于是他通过前面的atos命令获得了这个handler的符号名:plcrash_signal_handler,接着在所有库中查了一次,找到它属于PLCrashReporter模块,这是一个开源的闪退报告库。“这个handler做了些什么?难道它还能处理SIGSEGV吗?” 时间已经是深夜十点半,老Y的眼睛感觉有些干涩,一整天的注意力高度集中让身体有些吃不消。现在他已经不太想看plcrash_signal_handler的源码,当前的精神状态下完全看不进去,只是随便在Google上搜了这个方法,找到一篇PLCrashReporter的源码分析文章 ,简单浏览了一遍,但文章太长,他先记了下来,打算明天再看。 “从日志来看,SIGSEGV并没有注册handler,只有SIGABRT有一个奇怪的handler,难道是就是它导致的闪退被吞掉?”老Y看了看时间,发觉已经很晚,便想先以最快速的方式验证自己的猜想,“先试试如果将这个handler设置成空,是否就一定能够必现闪退了”。他将signal(SIGABRT, SIG_DFL)加在了进入业务之后,“这样就可以在执行业务的代码之前将handler设置为默认值,如果还是不闪退,则表明不handler的问题。但如果闪退了,则表示是这个handler的影响。”老Y对自己的推理感觉很满意,再次提交代码并进行打包,接着他摘了眼镜,闭上眼睛休息,想着实验的可能性。“已经被这个问题折腾了好几天,这下是不是可以找到突破口了?”,老Y满心期待。 十分钟过后,安装包构建完成的通知惊醒了老Y,短短的时间他竟然睡着了,他戴好眼镜,开始尝试这个安装包,果然如他所料,App崩溃了。“太好了,这便足以证明是SIGABRT handler的问题,接下来就只需要顺着这个handler做了去分析便能够找到问题所在”,老Y很激动,他将这一进展记录在笔记中,准备合上电脑之后,他突然像想起了什么,又打开电脑,补充了一句:“但这里还有一个问题,SIGABRT为什么还会处理SIGSEGV的信号?明天继续分析”。 写完后,老Y带着电脑离开了公司,此时已经接近12点。老Y开着车行驶在滨海大道上,虽然觉得有些疲惫,但因为三天来的分析终于有了进展,所以内心显得有些兴奋。他不停的想象着明天可能会有什么新的发现,“估计明天就能解决了吧”,老Y想。路上的车依然很多,不停有看似滴滴快车的电动车迅速从旁边驶过,广播中响起FM 104.9的音乐,午夜的音乐广播有种特殊的魔力,像是周围一切都暗了下来,为的便是有人对着耳边低声吟唱一般。不消一会便到了,老Y打开门,一盏温暖的夜灯亮着。 第4天 提速 前一天的发现让老Y信心满满,一大早来到公司就开始了接下来的实验。“昨天已经确定了是SIGABRT handler还原成默认值之后就正常,但为什么它会处理本该是SIGSEGV的信号?”带着这个问题,老Y开始在demo中做起了实验,在前面的demo中不再注册SIGSEGV,取而代之的是SIGABRT,其它没有任何变化。“如果程序依然不闪退,则表示空指针访问是可以被SIGABRT catch住”,虽然不太相信,但老Y期望是这样的结果。 可是等到程序运行时,结果却与他所想的完全不同,不管跑多少次都会发生空指针访问的闪退。又看了看前一天打印出的日志,“难道除了信号本身,还有另外一个参数flags是65有关?”,老Y不解的猜到,他又将flags设置成65。可依然得到的是同样的结果。这个结果让老Y喜忧参半,喜的是空指针访问就应该是SIGSEGV来处理,而不是SIGABRT。忧的是他还要继续寻找原因,“为什么还原了SIGABRT就会正常?” 老Y还得继续尝试分析原因,然后谨慎的在App中做实验,因为每次尝试都需要十分钟以上的时间,这让老Y感到效率太低,也是过去几天影响分析速度的一个很重要的原因。在刚刚的demo实验失败之后,他突然有了一个想法,足以大大加速这之后的分析过程。 因为在demo中测试信号时,他发现只有直接运行时,handler才会被处理,一旦连接Xcode联调,程序会直接挂在空指针的访问处,handler也无法被调用,老Y突然醒悟到:“之前本地调试时一直必现闪退,所以才在服务端打包。但会不会并不是本地与服务端打包的差别导致问题只在服务端的包中出现,而是由于使用Xcode联调?如果用本地打包,不联调,直接运行,会不会也能够重现出问题?”想到这点,老Y立马开始了尝试,首先它直接用Xcode调试,果然,程序挂在了空指针访问处。接着他停止了联调,直接打开程序,进入业务的页面。“会不会不闪退?”老Y竟然有些紧张,拿着手机的掌心微微出了些汗。 他尝试了一遍,奇迹般的,闪退并未出现。为以防是程序意外没有走到空指针的访问处,他又一次进行了尝试,依然没有闪退。随后老Y又去分析了一遍日志,确认了空指针访问处的代码的确被执行了,也通过压后台,得到了当时的堆栈,线程停留在空指针的那一行。“这样便确定了即使在本地也可以复现出问题,与服务端打包无关”,老Y为这次意外尝试得出的结论显得极其兴奋,因为所有的猜想与尝试都可以快速得到验证,而不需要经过服务端打包漫长的流程。 “从demo中看,只要是空指针访问都会产生SIGSEGV信号,那是否同样可以直接构建这一闪退,而无需通过业务的复杂入口?”,为了进一步简化复现流程,老Y打算构建新的场景。在应用启动之后,他打算根据固定的事件来触发不同的操作,目前需要的操作包含三个: 触发空指针访问 打印堆栈 恢复SIGABRT信号 他同时重写了应用的首页,添加三个按钮分别触发这三个操作,如此一来整体测试的流程大大简化,验证一个猜想也可以控制在一分钟以内。在构建了这样一个简化版的操作之后,老Y在笔记中写下了他的三个验证用例: 程序启动后,先触发空指针访问,此时应该不会闪退;接着打印堆栈,可以确定线程卡在空指针访问处; 从日志中找到SIGABRT信号的handler,分析是否依然是plcrash_reporter_handler 重启一次应用,先恢复SIGABRT信号,再次触发空指针操作,此时应该闪退 Xcode刷完应用后,老Y便开始了测试,用例1和3都如他所料,但第2个用例无法验证,因为atos的命令需要dsym文件,但刚刚编译时dsym的配置未打开。老Y重新设置好编译选项后,再次安装好应用,继续上面的用例,正当老Y满怀期望之时,意外发生了。第一个用例失败,空指针访问竟然立刻发生了闪退,他有些不敢相信,再次打开应用执行了一遍,依然闪退,而且堆栈也指向空指针的访问处。“为什么会这样?dsym应该不会影响结果,难道是覆盖安装影响?”,老Y将信将疑,删除了旧包,重新进行安装、测试,没想到三个用例成功的通过了。 “如果猜想是正确的话,修改代码,再次覆盖安装,用例1应该会失败”,老Y为了证明这个猜想,增加了一行日志,覆盖安装进行了验证,结果如他所料,覆盖安装后会导致闪退行为正常,handler并不会捕获闪退。 这个意外的发现让情况似乎又变的更加复杂了些,老Y停下来整理了目前得到的信息,“现象是release包空指针访问闪退,但debug包空指针访问不闪退,可以确定的是SIGABRT信号被注册,它捕获了本应是SIGSEGV处理的空指针访问,这里还有些奇怪,刚刚在demo中也验证了SIGABRT无法捕捉空指针访问,为什么App中就可以?如果将SIGABRT的handler还原成默认,则闪退立刻会出现。信号的handler是plcrash_reporter_handler,是由一个闪退报告的第三方开源库注册。另外一个意外获取的信息是首次安装运行时闪退才不会出现,但覆盖安装时会正常闪退。” 待老Y整理完所有的信息之后,他微微感到有些头痛,不停出现的奇怪现象让问题看起来越变越复杂,一个意外的出现可能随时就会推翻他之前得到的结论,但好在一个意外的发现也让得以本地验证,从而加速了问题分析的进程。这一天其它方面的事情也不断找上来,老Y不停的被打断,让他无法全身心的分析问题,来回之间的状态切换就像程序有资源消耗一样,也消耗着老Y的精力,在疲惫中老Y结束了一天的工作。 第5天 浮现 老Y本想花时间去仔细分析plcrash_reporter_handler的逻辑,可其源码的逻辑看似很复杂,于是他想先以更快的方式定位到原因。“既然是这个handler的影响,那么先看看这个handler具体是谁在什么时间注册的,如果关掉它是不是闪退就一定会出现?”基于这个想法,老Y先分析了App中main方法所在的Portal模块,终于在一个load方法中,找到了名为enable_crash_reporter_service的方法,看起来它便是启动整个闪退报告服务的地方。 老Y尝试将这个方法注释掉,看是否闪退还会被捕获。如他所料,当闪退报告服务关闭之后,空指针立刻触发闪退。为以防万一,他再次取消该注释,重新运行,闪退没有发生,证明该方法调用确实是引起问题的原因。在验证了debug环境之后,老Y又切到release,不管闪退报告服务是否打开,空指针一定会造成闪退。“由以上实验可以得出结论”,老Y在笔记中记录到,“debug环境下的闪退服务导致了空指针访问闪退被吞掉,但release不受影响”。“debug与release有什么区别,即使是debug包,Portal所依赖的所有模块已经是release版本,只有Portal本身不同,同时还有link的参数有区别”,老Y又开始了他的分析。 在老Y尝试打开闪退报告服务时,他意外的发现了另一个现象,如果是debug包首次安装,第一次运行时空指针访问时闪退不会出现,但是杀了进程第二次运行时,空指针访问竟然在接近10秒之后出现了闪退。这个意外的收获让老Y对之前的覆盖安装问题有了全新的解释,“难道这就解释了为什么覆盖安装时空指针访问会闪退的原因了?”,老Y接着推测,“其实并不是与覆盖安装有关,而是首次运行与非首次运行的缘故”,“一定是应用在首次运行时写入了某个配置,第二次运行可以读到该配置所以没有走异常的逻辑,导致现象不同。之前认为debug包下闪退会被吞掉其实还有一个限制条件,即首次安装运行时才会发生”,老Y为了验证这一推论,他又找了测试同学帮忙尝试线上业务是否也是同样的现象,即第一次不会闪退,但杀进程后再次运行会正常闪退。不一会,测试的结论也同步了过来,果然如老Y所料,debug包不会吞掉所有的闪退,仅仅是首次运行应用时才会发生。于是老Y顺着这个思路,结合前面得到的debug与release的差别,开始分析Portal的代码。 因为Portal的代码并不多,很快老Y便发现了一个可疑之处,有一段被DEBUG宏所包围的代码逻辑,用于清理缓存目录。本着快速排查问题的原则,老Y并不急于去分析这段代码,而是直接注释掉然后运行。奇迹发生了,debug包不再吞掉空指针的访问闪退。老Y感到十分兴奋,这下已经可以确定是由于闪退报告服务与缓存清理逻辑之间存在问题,可能是清理缓存导致了闪退报告服务的异常。 确定了清理缓存的逻辑存在问题之后,老Y开始仔细分析这段代码,想查出具体是哪个目录被清理掉导致的问题。经过几次试验,老Y定位到了Library/Caches目录。他又对比了清理缓存前后目录中文件的具体差别,发现在清理之前有一个闪退报告的缓存目录。“这下可以确定问题所在,由于debug环境下闪退报告的缓存目录被清理,导致闪退报告服务出现问题,所以闪退被吞掉。但第二次运行时,由于DEBUG宏之间的清理逻辑并不会被执行,因此后续的闪退并不会被吞掉。” 老Y接着分析,“闪退报告的目录应该是load方法中被闪退报告服务所创建,它早于main函数的执行,可在main函数中缓存目录又意外被清理,如果将清理逻辑提前到闪退报告服务启动之前是不是就正常了?”,老Y进行了尝试,一切如他所料,这种情况下所有的闪退均能够正常出现。老Y还发现,除了首次安装以外,在切换线上与线下环境时,也会触发清理缓存的逻辑,如此一来,很有可能许多业务在回归验证时,会让本该出现的闪退被吞掉,将一些问题带到线上?认识到问题的严重性,他赶紧联系了Portal模块的负责同学,并告知现在分析出来的结论,由他接手剩余的工作。 至此,老Y已经定位到是由于闪退报告服务的缓存目录被清理导致了闪退被吞掉,看起来已经可以结出结论,但现在依然有两个问题没有得到合理的解释,这让老Y并不愿到此为止。 首先是SIGABRT为什么会捕获空指针异常 为什么闪退报告的缓存目录被清理了之后闪退就会被吞掉? 此时已经是深夜11点,老Y感到已经离“真相”不远,“估计明天这个问题就会解决了吧”,老Y在笔记上记录了当前的进展及第二天需要从哪里开始查起的思路,便合上电脑离开了公司。 第6天 真相 经过前面几天的分析,还剩下两个问题待解决,一是SIGABRT为什么会捕获空指针访问异常。二是缓存目录被清理之后为什么会导致闪退报告服务异常并吞掉闪退。老Y一大早赶在其它同事过来之前就来到了公司,这样他就可以在正式工作开始之前快速解决剩余的问题,他已经花了太长的时间在这个问题上。 这两个问题都需要从PLCrashReporter的源码进行分析,现在该代码库由微软维护,但老Y没有立刻去分析源代码,而是从网上的文章入手,因为这能够帮助他以最快的速度找到切入点。这两篇文章详细的分析了PLCrashReporter的源码:PLCrashreporter源码分析其一及PLCrashreporter源码分析其二,它们很好的解释了plcrash_signal_handler的工作原理,如此一来,前一天遗留的第一个问题得到了解决。 原来有两种类型的异常,平常在使用Xcode联调时遇到空指针访问会收到EXC_BAD_ACCESS的错误,而这便是Mach层抛出的异常,这些异常会被host层转成对应的BSD信号,如空指针访问异常被转换成BSD标准的SIGSEGV信号抛到出错的线程。前面所说的SIGSEGV及SIGABRT都是BSD类型的信号。而在PLCrashReporter中,处理的便是Mach类型的异常。但同时,它还注册了BSD的SIGABRT信号,其handler为plcrash_signal_handler,用于捕获最终的闪退异常,具体原因可以参考下面的源码注释: “如此一来,便可以解释为什么App没有注册SIGSEGV却能够收到空指针访问类型的错误了,原来闪退报告模块已经从Mach层捕获了异常”,老Y喃喃道,“这个问题解决之后,接下来便只需分析为什么缓存目录被删除之后空指针访问异常被吞掉”。老Y直接clone了PLCrashReporter的源码,想看看最新的版本中这个问题是否已经处理。于是他模拟之前遇到的情况,在demo中启动闪退报告服务之后,立刻删除了缓存目录,运行起来之后果然空指针的访问无法引起闪退。“看起来这不是一个常见的case,否则这么久以来,怎么可能问题还没有修复”。 “因为是缓存目录被清理导致的问题,所以优先需要分析使用了该目录的代码”,老Y便开始搜索整个repo中使用该目录的代码,很快,他便找出一处可疑代码,如下图所示: 260行处的mach_exception_callback是PLCrashReporter处理Mach异常的回调函数,296行调用了一个函数mach_exception_callback_live_cb,这个函数会写闪退报告,如257行所示。但由于目标路径不存在,所以一定会写失败,从而296行的if条件命中,于是执行297与298处代码,问题就出了这里。 向上可以看到260行的返回值类型是kern_return_t,它是一个int类型的typedef,但是KERN_SUCCESS与KERN_FAILURE等定义如下: KERN_SUCCESS的值是0,而298处返回了false,它的值刚好是0,所以本应该是认为失败的场景,却因为返回false,而被当成了KERN_SUCCESS,误认为是成功,程序逻辑出现异常,这应该是开发者笔误写错了返回值类型导致的问题。“为什么返回了错误的类型就会出现空指针访问被吞掉呢?”老Y继续刨根问底。他找到这个callback的声明处,发现注释中赫然写着:“如果返回了KERN_SUCCESS,则线程会恢复执行”。 “原来是这样,因为错误的返回值,导致了空指针的访问线程被恢复执行,立刻再次触发Mach异常,如此陷入了死循环”,老Y想,为了验证这一想法,他在298处增加了一段日志,运行之后如他所料,不断的在打印这处新增加的日志,由此也证明了他刚刚的猜想。 为了防止后续有人再遇到这个问题,老Y向PLCrashReporter的github仓库发起了一个MR。至此,由一个阻塞提审问题引发的耗时长达六天的问题追踪终于告一段落,老Y长长松了一口气,开如准备复盘文档,接下来估计还有一阵枪林弹雨。 后记 老Y与这个问题斗争六天的故事也至此告一段落,后续的复盘会议中依然发现还存在其它的问题,老Y又花了近一天的时间分析并找到解释,但这篇故事已经够长且显得啰嗦,后面的事情有机会再聊。 从最终的修复方案上来看,技术上的问题似乎非常简单: 首先是debug环境下清理缓存的逻辑需要优化,不能够在初始化闪退报告之后又清理其创建的缓存目录 然后是PLCrashReporter在写入闪退报告失败后的bug 但就是这样一个简单的问题,为什么耗时如此之久才得出结论,导致复盘的时间一推再推,难点究竟在哪里,老Y在复盘时也进行了总结,主要包含以下几点: App复杂,依赖数百个模块,很难快速定位问题出自何处 开始几天只能在后台打包测试,Xcode无法调试,验证猜想的周期太长 后台debug包与release包相比依赖了一些测试模块,对推理过程造成干扰 测试过程中的出现一些奇怪现象:覆盖安装不出现、第一次不闪退第二次运行正常、不注册SIGSEGV也异常等也给推理造成了困难 。。。 正是由于以上一些因素组合起来,才导致了问题复杂度提升,让整个追查过程走了很多弯路。中间出现的异常现象在初次发现时,也曾被老Y当成是异常操作忽略过,并没有当成一回事,但最后发现所有测试中发现的所谓“奇怪”或偶然现象都不是偶然,而是某种条件下由bug引起的必然,都不应该被忽略,它们之中也许就藏着解决问题的关键性钥匙。在程序的世界里,任何问题只要能找出复现路径,问题就能得以复现。整个过程让老Y想起曾着迷过的侦探小说,解bug的过程就是破案的过程,任何蛛丝马迹都可能成为破案的关键。侦探根据获取的线索进行大胆猜想,并小心求证,一步步还原真相。 除了技术上的bug以外,该问题也同时暴露出沟通及开发流程中的一些问题,所以老Y也觉得在遇到这类问题时流程中有复盘这一环是个特别好的机制,开发、测试以不同的视角,对遇到的每个问题抛根问底,不会放过任何一个可疑的点,不存在无法解释的问题,任何问题都需要给出合理的解释,这样的复盘才不至于是为了流程而走个过场,才能保证如此大规模的App每次更新都维持最高的质量。出现问题并不可怕,可怕的是下次类似的问题一而再再而三的出现,这便是复盘的意义所在吧。 像这样的问题,作为一个程序员可能经常会遇到,尤其在如此大规模的系统中。程序员与bug缠斗的故事每天都在上演,而这六天,是老Y的故事。 (全文完) feihu 2021.01.07 于 Shenzhen

2021/1/7
articleCard.readMore

让小白PPT更加专业的设计书

前段时间在制作一个PPT时,效果总是不尽如人意,各种图表与文字完全不知放在哪里才合适。试了很多所谓好的模板,但很难将想要呈现的内容与这些模板完美的融合在一起,只能够生搬硬套模板中的样式,一旦版面的内容有所不同时,套用出来的效果就变得很差。网络上的PPT模板千千万,哪款才是适合的那一个?于是想着有没有一些通用的制作PPT的原则,可以让我们设计自己的模板,对于包含很多图表、数据、文字的页面,如何设计才能够让整个页面看起来没有那么业余,虽不一定要精美,但总不致于到难看的程度。看了很多介绍如何设计PPT的文章,但大多给出了结果,但不知道为什么要这么做,遇到自己的场景时依然没有头绪。还有很多文章都是介绍如何制作酷炫的动画,在使用PowerPoint的层面,这些都是术,有没有一种从更高层次来介绍如何设计的书?经过一番搜索,最终找到这本《写给大家看的设计书》。 所以这是一篇《写给大家看的设计书》的读后感,可能对于与我一样在制作PPT过程中充满困惑的朋友们有所帮助。 今天在飞机上看完了《写给大家看的设计书》,毫无疑问,这是看过的关于设计和排版类的文章和书籍中最好的一本。上周读完《演说之禅》,它解决的更多是演讲方面的问题,关于PPT的设计写的很简单,大多是巨幅图片当成背景,再配上一点简单的文字用于构造对比。可是我们平常的工作中需要制作的PPT远非这些简单的建议可以满足,PPT中需要有大量的图表、数据和文字。《演说》一书在这点上过于抽象,读完之后,对于制作PPT本身并没有太多的帮助。但《写给》这本书完全不一样,它针对的非从事设计专业的小白,书中提供了极其明确的操作指南,并伴随大量的练习。其中有大量的依据原则修改前后对比的案例,读完后有种恍然大悟的通透感,让人忍不住立刻想去实践一番。看着过去做的PPT,或者看到的一些广告、宣传手册等设计,都会想着运用刚刚学到的设计原则,去分析这些设计哪里好,哪里不好,为什么不好,如何改进才能够更好。 书中有一个词叫“设计师之眼”,像是一种很抽象的感官,或者说一种审美标准,它是将书中介绍的设计原则内化到大脑之后的能力。记得在《重构——改善既有代码的设计》一书中,作者介绍了很多的重构原则,要一一记住这些原则非常困难。但是,书中还提到了在什么样的情形下需要去做代码重构,用的也是一种类似的抽象感官,叫做“代码的坏味道”,它和这里的“设计师之眼”有着异曲同工之妙。也许去记住所有的原则,记住在什么情况下使用这些原则,如何使用它们并不容易,但从本质上来说,更好的方式可能是训练识别能力,识别好与坏的本能,比如识别代码中好与不好的地方,识别设计中冲突与和谐的地方,一旦掌握了这种本质能力,相当于将原则融入了骨髓,可以做到举一反三的地步。“代码的怪味道”训练的是“嗅觉”,“设计师之眼”训练的是“视觉”。 这本书非常难得的是它将看起来高深、抽象,难以描述的“感觉”,提炼成了放之四海皆准的四个原则,并且中英文场景通用,无比佩服作者提炼的功力。这四个原则简单、明确、正交,每个案例中,简单的依据它们做的小调整都可以看到立竿见影的效果。之前看待一些PPT的眼光,只停留在觉得这个做的好,那个看起来很丑,但具体好在哪里,丑在哪里却无法描述出来。因此对它们的改进也只能凭着感觉,套用模板,如同盲人摸象一般胡乱尝试,虽然在“美容”方面花了很长时间,却收效甚微。但看完此书之后,就像打开了新世界的大门,之前那些好与丑一下子变得清晰起来,我们也可以像专业设计师一样,从专业的角度去评判好与坏。但师傅领进门,接下来的修行看个人,如何培养“设计师之眼”变成了最重要的部分。 回顾下书中提到的四个原则:亲密性、对齐、重复、对比,这些原则也许你稍经留意,都可以在网络上各种文章与关于PPT制作的书籍中可以看到,它们都源自于此书。除了基础的四个原则外,书中还介绍了色彩与字体两部分,颜色可以从色带中依据一定的规则选取,冷暖色调有各自适合运用的地方。之前在PPT的配色都非常的随意,要么是五颜六色,要么是灰蒙蒙一片,而这本书将看起来完全由审美决定的配色,变成了任何人都可以参考执行的原则。读完后会感叹,原来那些和谐的配色是这样来的,也许我们也可以做得到。作为设计的灵魂——字体,其使用上有着几个原则,字体根据其结构可以分成六类,一些基本的原则,如不要选择同一个类别的字体,字体的对比需要强烈等,对比可以从字体大小、粗细、形状、结构、方向和颜色几个方面来突出效果,读完这章之后,再使用字体时肯定不会那么随意。 作者一直在强调不要居中对齐,除非你知道为什么要是用它,大部分情况下,左对齐和右对齐得效果更好,因为有一条很明确的对齐线。另外一点是不要使用Arial和Helvetica字体,因为它们太普通了。在过去,我一直大量的使用居中对齐及这两种字体,作为默认的方式,它们简洁、大方、正式、易读,可能作者从设计的角度,为避免设计作品落入俗套才有这样的论断吧。但在我们PPT的制作与真正的设计可能还不太一样,用一些奇怪的字体可能会显得很不合适,具体还是看场景而定吧。 这些原则并仅仅可以运用于工作中,如作者在结尾所说,生活中也可以使用它们,比如穿着搭配,墙壁上的画,都可以运用对比、重复性原则。平常看见家里小孩的玩具乱糟糟的,一个很重要的原因就是没有使用亲密性原则,这些玩具需要放在一起,比如一个盒子里,或者架子上,再可以运用对齐原则,让他们排列整齐,这是一个很有趣的看待问题的角度,但细细想来,的确可以尝试。 说了这么多的原则,在使用原则前,有一件非常重要的事情需要确定:这个设计中,最想要突出的主体是什么,有了这个前提之后,再来运用原则才能够避免本末倒置。 结尾 最后,理论、原则、案例都有了,接着来的便是实践。想要做到“设计师之眼”远非一日之功,需要投入大量的精力与练习。但念念不忘,必有回响,相信平时多加留意,对这些原则灵活运用成为习惯之后,也许某一天我们这些设计小白们也可以培养出“设计师之眼”,做出自己满意的“专业”设计。 (全文完) feihu 2019.07.03 于 Hangzhou

2019/7/3
articleCard.readMore

WWDC之AR

进入iOS开发这些年,今年终于有机会去参加一次WWDC,朋友们笑称,这是趟朝圣之旅,我想也并不为过。 目录 背景 AR@WWDC USDZ格式 AR Quick Look 与Adobe合作 尺子app 共享地图 乐高世界 ARKit 2.0新特性 地图持久化 World Tracking增强 环境纹理 图像跟踪 物体识别 人脸跟踪 其它 Siri Shortcuts Metal ML Xcode 10 macOS FaceTime 非技术 视频地址 写在最后 背景 与往年的抢票不同,今年的门票采用随机抽取的方式,早在三月份就开始了WWDC08的门票登记,而我有幸抽中门票,随后开始了漫长的签证。运气很不好,不知是否是由于中美关系紧张的缘故,IT相关的从业者很容易被行政审查。我在广州大使馆也不幸被抽中,当时准备了WWDC的邀请函,丝毫没有用处。面试官问有没有准备个人简历与旅行计划,这些在网上看攻略时都说不用准备,没想到却刚好被问到,行政审查,让回去准备这些材料并发到大使馆的邮箱。于是开始了漫长的等待期,平均时间是25天左右就能够issue签证,我的却一直等了42天,中间写了几次邮件去催促,无果。准备放弃之际,却在5月底拿到签证,接着匆忙的准备酒店、机票等事宜,赶上了这次WWDC。 AR@WWDC 这次参会主要关注AR方面,AR在今年的大会上占尽了风头。Keynote主题演讲时,iOS 12的性能数据介绍之后,立刻开始了AR相关的介绍,作为iOS最重要的特性登场。下面带着大家一起看看这次的AR都包含了哪些内容。 USDZ格式 第一个主题,苹果发布了新的格式USDZ,它是基于Pixar开源的USD格式,不同的是前者把所有文件都打包成了一个文件,为分享做了一些优化,并且在Xcode 10中集成了打包转码工具。这个发布很有意思,苹果似乎总是与业界对着来,关于3D文件格式,Khronos组织已经与2015年就发布了glTF格式,并且已经在各大厂商、游戏引擎中得到了推广,Khronos声称,glTF就是3D格式中的jpeg,俨然一副建立业界新标准的姿态。即使在glTF已经占有如此大优势的情况下,苹果仍然执意发布自己的3D格式,就好像Metal与Vulkan,苹果自己构建一道与业界的屏障,也许是因为它可以完全控制自己的硬件与软件,不需要因为兼容性去妥协设计吧。至于最终USDZ能否像glTF一样成功,看后面苹果的推广了。 这里对glTF与USD/USDZ作了一个简单的对比:   glTF USD/USDZ 用途 兼容GPU的数据格式,快速加载 用于分享,扩展 支持 Khronos, Facebook, Google, Microsoft, Adobe Apple, Pixar 引擎 UE4, Unity 3D, godot, three.js, Blender, Adobe Apple 工具 集成的转换器,优化,校验,编辑工具等等 Xcode命令行 版本 2.0 1.0 发布时间 2015.10.19 1.0/2017.6.5 2.0 2012 USD/2018 USDZ 开源 开源 2016年开源 目前看来,USD/USDZ全方位的落后,不过因为它是刚刚发布,相信凭着苹果的运营,会得到越来越多的支持与推广。 苹果推出这个格式,有一个非常重要的原因,为了AR Quick Look。 AR Quick Look 本次大会一个非常重要的发布,AR Quick Look,这是一个什么东西?可以理解为默认的图片浏览器之于图片,这个就是3D文件的浏览器,它将在下一个版本的iOS、macOS中原生支持,已经支持了众多的原生应用,如下图所列: 另外苹果做了件事让这个更加容易推广,可以像开发者直接使用MPMoviePlayerController来播放视频一样,它将AR Quick Look也开放给开发者,使开发者可以通过简单的代码就集成这个工具,让应用支持AR能力。 既然叫AR Quick Look,自然不仅仅能展示3D模型,还可以与实景结合起来,直接使用ARKit的跟踪功能,体验非常好。看下面的两个例子: 看到这里,似乎明白苹果为何要发布新的3D格式了,这两者的结合不仅让3D模型可以更方便的在iOS与macOS里面原生支持,并且开发者可以通过两种方式简单的使用强大的能力:在app中集成AR Quick Look或者在H5的网页中使用新提供的<ar>标签。尤其是后者,此处可以展望一下,继video标签之后,ar很可能会被纳入到H5的标准之中,只不过可能格式不是使用USDZ,而是支持更加广泛的glTF吧,我们拭目以待。 接入AR Quick Look之后,会随着它本身的升级而自动升级,极大的降低了3D模型与AR场景的门槛,苹果下得一手好棋。 与Adobe合作 一个巴掌拍不响,3D格式的支持离不开其它大厂商的支持,苹果已经与Autodesk、Adobe等厂商合作,尤其是Adobe,他们的CTO被请来大会现场,专门展示了为苹果新的USDZ与AR Quick Look开发的工具链,可以在Adobe强大的工具链中完成从设计到绘图,到3D制作,到生成3D模型,最终发布在苹果的生态系统中,十分强大,一起来感受一下。 尺子app 苹果将一个尺子app列入iOS 12系统的默认软件中,它可以利用ARKit的能力来测量真实世界中的物体长度。由于在iOS 11 beta版本发布时,就已经看到过了类似的应用,所以再次看到时并没有很激动。只是在体验上苹果做得更好,像计算器一样集成在手机中,为生活提供了极大的便利。 从与苹果工程师的沟通中了解到,这个精度比较高,只有5%以内的误差,1m只是不到5cm的误差,基本可以满足生活中的一般测量需要了。 共享地图 终于,苹果将这个功能开放了出来,3D的虚拟地图可以共享,如此一来,多个用户看到的可以是同一个虚拟世界,多人互动的AR游戏便成了可能。相信这个发布之后,AR游戏将会迎来一个大暴发。 大会展示了一个双人AR游戏,还有一个观看者,三者同时处于一个虚拟现实结合的世界中,好像将游戏中的功能搬了出来,直接由AR单机游戏变成了AR联机游戏,游戏性将极大丰富。 这个AR游戏也成为了一个开源的sample可以下载,并且还有其开发者手把手解析这个游戏的代码,感兴趣的同学可以去观摩下这个视频: Inside SwiftShot: Creating an AR Game。 乐高世界 上面的游戏还不过瘾,苹果又邀请了一家大公司来撑场面:乐高。他们基于前面提到的共享地图与后面将要介绍的模型识别,将乐高玩具极其真实的还原到一个AR游戏中,趣味性十足。想想这种场景,孩子拉着父母来到乐高店中,店里摆放了众多的乐高模型,拿起手机一扫,AR便将这些模型直接搬到游戏之中,孩子可以在AR游戏中体验各种乐高模型,在极大的满足之后,离开店里是不是就到了父母们掏腰包的时间:-P。 因为还提供了保存的功能,孩子们还可以将游戏进度保存下来,下次进店接着上次结束的地方继续游戏,孩子们一定会爱死这个功能。 一起来感受下乐高的世界: ARKit 2.0新特性 介绍完前面提到的AR能力之后,再来从开发者角度看看iOS 12为开发者带来了哪些新特性。ARKit 2.0将在这个版本发布,经历了开创性的ARKit 1.0与持续增强的ARKit 1.5版本之后,新版本ARKit又迎来了较大的更新,AR体验将进一步得到提升。 地图持久化 这是2.0版本提供的最重要的功能,前面已经分析过,有了它之后,多人互动成为了可能。同苹果的工程师了解了下,这与重定位的原理类似,在1.5版本中,ARKit提供了压后台再回前台之后的恢复功能,两者的原理一样。这个所谓的地图中存储了哪些信息呢?如下图: 主要包含: 特征点及其描述子。 已经放置的锚点信息,如此一来,之前在检测到平面,以及其它用户放置的模型都可以保存。 还有原始的特征点,即接口中提供的raw data。 下面是一个直观的地图建立的过程: 正是因为提供了地图持久化的功能,用户之间可以分享地图,也可以保存再加载地图,类似于游戏中保存进度一样,游戏性大大提升。 本以为这个功能可以用在室内导航中,但是咨询苹果工程师之后,由于数据量的原因,这只能够支持普通房间大小,所以不能够直接用于室内导航,可能需要多个地图拼接在一起才行。 World Tracking增强 ARKit 2.0对于world tracking做了大量的增强,主要包含以下方面: 更快的初始化速度与平面检测更快。如果熟悉ARKit版本的同学,可能会知道,在ARKit 1.0版本时,初始化的速度非常慢,一般需要5s左右的时间才能够结束,在这5s的时间内,相机只能够传递传感器的方向信息,并没有深度的变化。所以在app的设计上都存在一个引导的过程。为此我们在支付宝中基于slam算法对其专门做了一个优化,一帧就可以初始化完成,解决了初始化引导的问题。到了ARKit 1.5,初始化速度有了提升,只需要3s左右;而到了最新的版本,据说只需要1s左右就能够完成,这样体验会大大提升,可以抛弃长时间的引导动画了。 跟踪与平面检测更加稳定。 更加精确的平面检测。这与前面提到过的误差范围只有5%有关系,在之前的使用过程中,经常会出现检测到的平面边界无法与真实平面吻合的情况,对真实性的还原有一定的影响。优化之后,这类情形会得到改善。 持续对焦。这个功能其实在ARKit 1.5版本就已经支持,预览画面会好很多,否则会出现画面一直模糊的情况。同时跟踪的稳定性并没有受到影响。后面将要提到的图像跟踪与物体跟踪也同样支持。 增加了4:3的视频帧采集。第一个版本时,只能够使用1280P的视频帧,现在更加灵活。可能后续会开放更多的AVCaptureSession的参数。 环境纹理 在ARKit 2.0中,苹果引入了一个除地图保存以外最令人激动的功能——环境纹理,它可以大大提高渲染真实性。环境纹理能让模型反射其它物体,反射的可以是虚拟物体,也可以是由真实环境建立的真实物体。它是利用天空盒子提供的纹理信息,当成光照来达到反射的目的,这一过程也叫做Image Based Lighting,这一关键点在于提供天空盒子。ARKit 2.0支持直接从摄像头拍摄的真实场景中获取这个天空盒子,它是自动构建的,不需要360度扫描。并且这个天空盒子还可以自动更新,至于更新的时机完全由内部机制决定。下面这个视频展示了天空盒子的构建过程: 从过程来看,其实是运用了ARKit的平面检测功能,它将平面上的纹理大致的贴到了天空盒子对应的平面中,随着平面信息不断的更新,天空盒子中的纹理也逐步完整。 对比下使用环境纹理之后的效果: 可以看出,环境纹理功能打开之后,虚拟的金属碗逐渐反射出来真实世界的香蕉和桌面,真实性得到极大的提升,可以说,它是虚拟与现实融合的纽带。 图像跟踪 自从ARKit 1.5版本提供了图像识别之后,图像跟踪也在这个版本得到了支持。做得非常好的一点在于,帧率高达60fps,跟踪效果极其稳定,同时可以多个跟踪目标。可以看下面的视频,红绿蓝的坐标轴是虚拟的模型: 模型与图片帖合的非常好,并且跟踪过程稳定。支付宝在第一个AR就已经支持了图像跟踪,处理时间只需要3ms左右,可以达到60fps,但目前只能够跟踪一个目标。 因为图像跟踪对图片的质量有比较高的要求,图像内容越丰富,跟踪的效果越好,所以Xcode还提供了图像质量检测功能,如下面的两个识别图: Xcode结出了非常详细的警告信息,如直方图的分布过窄、重复性结构过多、纯色区域过多等,同时给出了优化的建议,这个功能非常赞。在这一点上,支付宝的AR平台是在上传跟踪图像时,在服务端对图像进行打分判定是否能够跟踪,只有过了分数线才支持跟踪。但并没太多的提示信息,对于经验丰富的开发者,这点不是问题,但如果是新手,可能不太友好。 苹果给出了一个效果更酷的例子,直接可以在图像跟踪的位置播放视频: 这个功能支付宝也早在一年前已经支持,并且更进一步,我们还支持透明视频: 物体识别 图像识别可以适用于很多场景,但对于某些场合,仅用一张图像不够的,比如博物馆的展品。用户可能从任意角度去扫描这些展品,如果使用图像识别的话,需要不同角度非常多的识别图像。有没有更好的方案?苹果给出了物体识别,不同于图像识别,物体识别可以从各个角度识别物体,完美的适用于前面提到的博物馆场景。识别物不再是多张图像,而直接是一个3D的物体。如下视频所示,从任意角度识别到一个展品之后,在对应的位置放置一个展品信息的模型,后续的跟踪还是world tracking。 图像识别是提供图像,而物体识别则是提供一个预先扫描好的模型,所以它的制作成本稍高。这个模型有点类似于前面提到的地图,它也是一些特征点等信息的集合,原理上应该与可共享的地图一致。苹果还提供了一个专门的工具来对物体进行扫描,降低了识别模型的制作成本。 人脸跟踪 从iPhone X开始,苹果引入了前置深度相机,这次也有一些新功能的发布,如支持眼球跟踪: 以及支持舌头跟踪: 这让各种表情变得更加有趣了。但由于这些是基于深度相机,所以只能在iPhone X这款支持前置深度相机的机型才支持。 其它 除了AR相关的新特性以外,这次大会还有些其它有意思的内容,不妨也在这里和大家分享一下。 Siri Shortcuts 这是一个能够将手机上的应用都连接起来的功能,类似功能可以怎么玩,网上有个段子脑洞很大: 一旦老婆的Twitter上出现“加班”字样,立即激活一条手机短信通知。同时,自动检测谷歌日历,找出几个今晚没有事情的老友。随后,在Facebook上新建一个活动“今晚喝大酒”,一旦超过3人同意,触发一条订餐消息给餐厅。餐厅查询Evernote,找到这群人最喜欢的菜和酒。 也类似于现在的智能家居,比如地图上检测到我即将到家时,打开客厅的灯,空调根据当前季节调到合适的温度,放首我最喜欢的音乐。这不算什么新东西,类似的服务workflow、ifttt已经存在多年,可以连接应用,只是国内支持的服务很少,国外也感觉只在开发者中间流行。 苹果是在2017年收购了workflow,现在将Siri与其结合,推出新的shortcuts,由于与Siri可以很好的整合在一起,一方面,Siri的能力更强,正在努力摆脱玩物的尴尬场面。另一方面,有了Siri之后操作shortcuts更加方便,也更加智能。第三方应用也可以提供接入shortcuts的功能,这将赋于shortcuts更加强大的能力。同时Siri可以根据用户不同的习惯提供不同的建议。将来会不会有类似于app store一样的小平台,提供shortcuts的分享,这个功能将来变成什么样,非常值得期待。 Metal 苹果已经坚定了要大力发展它的API: Metal,从下个版本开始OpenGL/ES将会废除,不再提供更多的支持。我在Labs中提了一个OpenGL ES不能在iOS 11的机器上Capture GPU Frame的问题,本来与苹果工程师聊的时候,他说是应该支持的,让我提了bug,结果没多久便收到bug被closed的消息,理由是没有修复的计划。OpenGL/ES的玩家将要无情的被抛弃了。 虽然说要被废除,但是就像其它的废弃API一样,还有一段时间的生存期,苹果一样会留给开发者很长的缓冲时间。现在依然有很多iOS 2.0版本的废弃API还能够运行。 不过苹果在Metal上的优化,非常让人动心,并且专门有Session来讲述如何从OpenGL/ES迁移到Metal来,还请了一个游戏公司的开发者来讲述他们迁移的经验,游戏性能获取了巨大的提升,也并没有太大的工作量,这样结果似乎更加令人信服。 前面提到Xcode可以Capture GPU Frame,现在在Metal更进一步,可以调试shader,这一点真的是太赞,将极大的提升shader的编程,但是很可惜,仍然只能够用在Metal中,OpenGL/ES是无法享受此待遇的。 ML AR与ML是这次大会上非常重要的两个主题,ML也迎了较多的更新。首先它提供了一个新的CreateML的框架,可以直接用MBP通过playground来训练模型,10行以内的代码就搞定,并且模型压缩比高的夸张。由于是直接在Metal基础上开发,所以可以利用GPU的高性能来对模型进行训练,宣称MBP可以用48分钟训练完其它机器上需要24小时才能训练完成的模型。 最后一个有趣的是,ML直接支持语义分析,并且支持多国语言,包含中文。 Xcode 10 Xcode 10有几个比较有趣的更新,支持多光标、列选择,这两个功能早在很多现代的编辑,VIM,Emacs中就已经支持,不算惊喜。 另外一个功能是可以并行运行UT,这个功能很好,现在我们的UT在simulator上面运行一次需要半个小时左右,支持并行UT之后,可以极大的减少UT的时间。它的原理是通过clone出来多个simulator来执行,每个simulator分配了不同的测试用例,执行完成之后,再将结果汇总起来。之前在西门子时,我们也尝试过使用Hadoop来并行跑测试用例的方案,与这个原理一样。 macOS macOS也将迎来新版本macOS Mojave,不过没想到的是在主题演讲时第一点竟然介绍的是dark mode,顿时倍感失望,仅仅是一个主题,竟然作为一个新系统版本的主要卖点着重介绍。 有一个比较激动的点,后面版本会让iOS上的应用非常方便的移植在Mac上,大大的降低两个版本之间的开发成本,iOS上这么多年积累的优秀应用也许也可以在Mac上大放异彩。 这个版本持续的在与手机端无缝合作上下功夫,苹果在体验上的不懈追求宠坏了用户,让用户不知不觉间就习惯了这种特性,觉得这似乎是理所当然的,没有这个功能反而觉得奇怪,比如之前的Mac与iPhone剪贴板打通,非常体贴。 FaceTime facetime下个版本将支持最多同时32人通话,查了下微信、QQ,都是最多添加9人,但由于受限于平台,在国内还是无法流行起来,微信与QQ是更好的方案。但从技术层面来说,32人同时通话需要大量的带宽与渲染性能,看起来非常了不起。 非技术 其它还有一些非技术方面的体验也让我留了下很深的印象。大会包容万象,下至10岁,上至82岁,有身体健全人士,也有听力障碍人士,大会上不止一次的看到听力障碍人士对面坐着一个手语翻译工作人员给他们翻译演讲的内容。头发花白、胡子大把的开发者随处可见,大家来自不同国家,说着不同的语言,都聚集于此。 大会的形式也颇有新意,采用Session与Labs结合,Session类似学校时的课堂,不仅讲述新的特性,很多开发者可能会遇到的问题也会独自成章。开发者们可以参加自己感兴趣的主题,四天时间总共有一百多个Session。 除了Session以外,专门还有一对一的答疑时间,叫做Labs,这可能是WWDC门票里面最值回票价的环节,开发者可以就几乎任何相关的问题与苹果工程师一对一的对话。当然,不限于新特性,也可以咨询在使用这些framework中遇到的问题。除了这些技术性的Labs,还有UI Design Labs,可以就自己正在开发的app向苹果工程师咨询建议,但这种Labs需要提前预约,预约成功之后将得到半个小时的建议时间,非常宝贵。 在午餐时间,苹果还邀请了一些高校和企业来做分享,我去听了一个大学教授关于科技运用到考古学上的实践,还有一个Pixar的光照部门主管关于Pixar一些经典影片开发过程的分享。国外的企业与高校还有其它行业的结合非常紧密,比如很多软件对学生免费,这算是一个双赢的局面,企业既可以提升企业的公众形象,获取更好的政府及学校生源支持,还能够将科研领域的最新研究成果落地商业化。同样高校也可以通过企业的宣传机会,将自己的科研领域分享给大众,吸引更多的人来参与。这点国内的企业似乎做的不足,有种高校与企业各自发展,脱节的感觉。 最后一点,虽然乔布斯已经不在,但主题演讲仍然极受欢迎,大家热情不减,9点开始大会,凌晨3点就很多人去排队,为了可以离演讲台更近一些,似乎我们只有买火车票与孩子幼儿园报名才这样:-P。 视频地址 这里附上AR相关的session视频地址: keynote: https://developer.apple.com/videos/play/wwdc2018/101/ ARKit 2.0: https://developer.apple.com/videos/play/wwdc2018/602/ AR Quick Look: https://developer.apple.com/videos/play/wwdc2018/603/ 多人游戏: https://developer.apple.com/videos/play/wwdc2018/605/ 理解跟踪: https://developer.apple.com/videos/play/wwdc2018/610/ AR体验设计: https://developer.apple.com/videos/play/wwdc2018/805 写在最后 这是一个Geekers们的聚会,大家调侃这是朝圣,这是xie教聚会,大家狂热又专注,不分国度、肤色、语言,不远万里聚集于此。即使所有的Session内容都有视频,但就像你最爱的演唱会即使有DVD,也一定要去现场感受一次一样。对了,这里还是为数不多的男厕排长队,而女厕空荡荡的奇妙地方。这里是WWDC。 (全文完) feihu 2018.07.02 于 Shenzhen

2018/7/2
articleCard.readMore

iOS中如何对具有复杂依赖的SDK在真机上进行单元测试

单元测试在软件开发中一直有着极其重要的地位,iOS的开发也不例外。随着App规模的不断膨胀,开发也逐渐的趋向模块化,开发者常常以库的形式封装功能,最后组成App。此时由于App结构变得复杂,各种库又可能存在着相互依赖的缘故,单元测试也随之变得复杂起来。开发者可能面临着一系列问题,比如:单元测试如何处理这些依赖?如何在真机上运行测试?如何在App所在的环境中运行测试?本文将用一个模拟的开发环境逐一进行讨论。 目录 问题 搭建SDK开发环境 第三方库:EC3rdFramework 开发中的SDK:ECSDK 最终应用:ECApp 添加单元测试 编写测试用例 让测试运行在App环境 让测试运行在真机上 特殊情况 结尾 在刚刚接触软件开发时,从未想过要写单元测试,总觉得自己写的代码质量很高,根本不需要测试。需要将宝贵的时间放到开发上,测试是测试人员的事情。后面才发现,经常因为一个小需求的增加,动了一处代码,结果其它地方出现重大问题,没测试到就上线了。甚至到了后面,代码复杂度越来越高,每动一处代码都提心吊胆,生怕有其它情形未考虑到,如履薄冰。经历了很多次惨痛教训之后才醒悟过来,单元测试是保证代码质量的不二法则。在《代码重构》一书中,每进行一步重构,作者都会先运行一遍单元测试,然后再进行后面的重构,因为只有这样,才能够保证重构之后代码的正确性,如果连正确都无法保证,重构有何意义? Apple从Xcode 5开始,引入了最新的测试框架XCTest,非常完美的将测试与开发环境集成在了一起。关于如何使用XCTest,网上有非常多的介绍,大家可以看看Apple的官方文档,NSHipster也写过一篇文章:Unit Testing。 随着开发者越来越重视单元测试,有人提出了TDD(测试驱动开发),并得到了很多开发者的推崇。这种思想会先根据需求或者接口来编写测试用例,然后才开始写业务代码,这样极大的保证了写出来的代码的正确性。关于在iOS上使用TDD,OneV写过一篇TDD的iOS开发初步以及Kiwi使用入门,有兴趣的可以去看看,这里不再展开介绍,本文集中讨论下面特定场景中的单元测试。 问题 iOS开发现在多数都使用CocoaPods进行第三方库的依赖管理,这样开发者们可以集中注意力放在自己模块的开发上面。比如著名的网络库AFNetworking。它的开源代码中也包含了单元测试,写得非常好,可以作为范例去学习。 但是由于AFNetworking本身的特点,决定了其单元测试环境其实是比较简单的,比如: AFNetworking算是一个独立的库,并没有依赖其它的第三方库 不依赖复杂的App环境 不依赖真机环境 然而,很多时候,我们的开发环境比AFNetworking复杂得多,比如: 依赖其它的第三方库,如何处理这些依赖的问题? 依赖的某些第三方库又必须运行在复杂的App环境中,如何让测试运行于App环境? 某些方法必须在真机上才能运行,如何让测试运行于真机上? 这些问题AFNetworking的测试用例都没有,而且默认创建的测试target都无法运行在这些环境中。如何利用XCTest来对以上复杂情形下的SDK进行单元测试?我们从模拟以上开发环境开始。 搭建SDK开发环境 首先我们来搭建一个满足以上复杂条件但却典型的开发环境:创建三个工程,其中ECApp是最终应用,它依赖了我们正在开发的ECSDK,而后者又依赖了第三方库EC3rdFramework。整个目录结构为: . ├── EC3rdFramework │   ├── EC3rdFramework.podspec │   ├── EC3rdFramework.xcodeproj │   ├── Podfile │   ├── Sources │   │   ├── ECFoo.h │   │   └── ECFoo.m │   └── SupportingFiles ├── ECApp │   ├── ECApp │   │   ├── AppDelegate.h │   │   ├── AppDelegate.m │   │   ├── Assets.xcassets │   │   │   └── AppIcon.appiconset │   │   │   └── Contents.json │   │   ├── Base.lproj │   │   │   ├── LaunchScreen.storyboard │   │   │   └── Main.storyboard │   │   ├── Info.plist │   │   ├── ViewController.h │   │   ├── ViewController.m │   │   └── main.m │   ├── ECApp.xcodeproj │   └── Podfile └── ECSDK ├── ECSDK.podspec ├── ECSDK.xcodeproj ├── Podfile ├── Sources │   ├── ECUsingFoo.h │   └── ECUsingFoo.m └── SupportingFiles 第三方库:EC3rdFramework EC3rdFramework是我们开发的ECSDK所依赖的第三方库,其中包含一个ECFoo类,含有三个方法,分别模拟三种场景: // ECFoo.m // 模拟不依赖任何环境 - (BOOL)methodDependsOnNothing { return YES; } // 模拟依赖应用的环境 - (BOOL)methodDependsOnAppEnv { NSNumber *appInitialized = [[NSUserDefaults standardUserDefaults] objectForKey:@"AppInitialized"]; if (appInitialized) { NSLog(@"running in app env"); return YES; } else { NSLog(@"NOT running in app env"); return NO; } } // 模拟依赖真实设备 - (BOOL)methodMustBeRunningOnDevice { #if TARGET_IPHONE_SIMULATOR NSLog(@"running on simulator"); return NO; #else NSLog(@"running on device"); return YES; #endif } 三个方法非常简单的模拟了三种典型的场景,满足条件时才会返回YES,代码很简单。对于依赖应用环境的场景,是通过App设置的一个标志位来判断,后面ECApp部分会看到这个标志位的设置。 其podspec如下: Pod::Spec.new do |s| s.name = "EC3rdFramework" s.version = "1.0.0" s.requires_arc = true s.source_files = [ '**/Sources/**/*.h', '**/Sources/**/*.m'] s.ios.deployment_target = '7.0' end 开发中的SDK:ECSDK ECSDK为我们所开发的SDK,它同EC3rdFramework一样,也是一个静态库,包含ECUsingFoo类,与前面的ECFoo类包含相同的方法,每个方法直接调用ECFoo中对应的方法,这样做是为了模拟依赖第三方库的场景: // ECUsingFoo.m #import <EC3rdFramework/ECFoo.h> // ... - (BOOL)methodDependsOnNothing { ECFoo *foo = [ECFoo new]; return [foo methodDependsOnNothing]; } - (BOOL)methodDependsOnAppEnv { ECFoo *foo = [ECFoo new]; return [foo methodDependsOnAppEnv]; } - (BOOL)methodMustBeRunningOnDevice { ECFoo *foo = [ECFoo new]; return [foo methodMustBeRunningOnDevice]; } 同前面类似,它的podspec如下,区别在于它多了对第三方库的依赖: Pod::Spec.new do |s| s.name = "ECSDK" s.version = "1.0.0" s.requires_arc = true s.source_files = [ '**/Sources/**/*.h', '**/Sources/**/*.m'] s.dependency 'EC3rdFramework' s.ios.deployment_target = '7.0' end 也是因为这个依赖,还需要一个Podfile: target "ECSDK" do pod 'EC3rdFramework', :path => '../EC3rdFramework' end 最终应用:ECApp ECApp为使用ECSDK的App,它启动之后立刻调用ECSDK中暴露的接口: // AppDelegate.m #import <ECSDK/ECUsingFoo.h> // ... - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // Override point for customization after application launch. [[NSUserDefaults standardUserDefaults] setObject:[NSNumber numberWithBool:YES] forKey:@"AppInitialized"]; [[NSUserDefaults standardUserDefaults] synchronize]; ECUsingFoo *foo = [ECUsingFoo new]; [foo methodDependsOnNothing]; [foo methodDependsOnAppEnv]; [foo methodMustBeRunningOnDevice]; return YES; } 方法的前两行,先设置了App环境的标识,前面ECFoo中便是依赖于此标识来判断是否处于App的环境中。 它的Podfile也很简单: target "ECApp" do pod 'EC3rdFramework', :path => '../EC3rdFramework' pod 'ECSDK', :path => '../ECSDK' end 这里有一点需要注意的是,实际上ECApp不会直接去依赖EC3rdFramework,它是被ECSDK依赖,按理说不需要加到Podfile中,CocoaPods会帮我们处理这种依赖。但由于EC3rdFramework并非已经发布的第三方库,如果不加上这一句的话,在pod install时会出现下面的错误: [!] Unable to find a specification for EC3rdFramework depended upon by ECSDK CocoaPods会去已经发布的库中去寻找,而不是本地。同时由于CocoaPods也不支持在podspec中像Podfile中一样,通过:path => ../EC3rdFramework指定本地路径,StackOverflow这里有讨论,所以采用这种变通方法。但这并不影响我们演示。 在ECApp路径下执行pod install之后,然后编译运行,将会得到以下日志: 2016-02-16 21:27:32.032 ECApp[30005:1064278] running in app env 2016-02-16 21:27:32.032 ECApp[30005:1064278] running on simulator 表示库已经正常调用,运行于App环境中的模拟器上。我们需要进行单元测试的开发环境搭建完成。 添加单元测试 环境搭好之后,接下来,为ECSDK添加单元测试。由于Xcode集成了XCTest,所以添加单元测试非常简单,依次选择菜单项:New/Target/iOS/Test/iOS Unit Testing Bundle,这里我们的测试target为ECSDKTests。完成后,在ECSDK工程中会生成对应的target和源文件,可以看到工程中有一个ECSDKTests.m文件,这是Xcode默认生成的测试用例,是来打酱油的,什么事都没做。选中ECSDKTests这个Scheme,按下⌘ U(注意,这里是U,而不是平时所用的B和R)编译并运行测试,因为此时是默认的空测试用例,所以测试很顺利的完成: 编写测试用例 为了测试ECSDK中提供的方法,我们需要为其添加新的测试用例。三种场景,只有返回YES时才算通过测试,由此表示测试可以运行于这些环境中: // ECSDKTests.m #import "ECUsingFoo.h" // ... - (void)testMethodDependsOnNothing { ECUsingFoo *foo = [ECUsingFoo new]; XCTAssert([foo methodDependsOnNothing], @"The method must be running in ANY env"); } - (void)testMethodDependsOnAppEnv { ECUsingFoo *foo = [ECUsingFoo new]; XCTAssert([foo methodDependsOnAppEnv], @"The method must be running in app env"); } - (void)testMethodMustBeRunningOnDevice { ECUsingFoo *foo = [ECUsingFoo new]; XCTAssert([foo methodMustBeRunningOnDevice], @"The method must be running on device"); } 测试用例很简单,我们来看看是否可以运行。再次选中ECSDKTests这个Scheme,⌘ U编译运行,此时出现以下错误: Undefined symbols for architecture x86_64: "_OBJC_CLASS_$_ECUsingFoo", referenced from: objc-class-ref in ECSDKTests.o ld: symbol(s) not found for architecture x86_64 clang: error: linker command failed with exit code 1 (use -v to see invocation) 错误信息显示链接时找不到ECUsingFoo方法,后者是定义在ECSDK工程中,表示测试target需要依赖ECSDK。处理依赖有多种方法:可以在Build Settings中添加库及其对应路径。还有一种更好的办法,利用CocoaPods,在它的Podfile中增加一个target即可,这样可以保证ECSDKTests和ECSDK的依赖完全一致。新的Podfile像这样: def import_common_pods pod 'EC3rdFramework', :path => '../EC3rdFramework' end target "ECSDK", :exclusive => true do import_common_pods end target "ECSDKTests", :exclusive => true do import_common_pods pod 'ECSDK', :path => '.' end 因为依赖了共同的库,所以将这抽出来成为一个单独的方法,接着在两个target中调用。由于测试target是依赖于ECSDK,所以还需要加上:pod 'ECSDK', :path => '.'。重新pod install,⌘ U,编译问题解决,测试可以正常运行。 但现在面临两个新的问题,因为现在测试只能运行于模拟器上,而且并非是App的环境,所以后面两个测试无法通过。 如果我们直接将Scheme选成真机上运行,一按⌘ U便会弹出以下错误提示: > Logic Testing on iOS devices is not supported. You can run logic tests on the Simulator. 暂时无法运行于真机上。 让测试运行在App环境 我们先来看如何让测试运行于App环境中。Apple在开发文档中提过两个概念,一个叫Logic Tests,另外一个叫Application Tests,前者表示简单的逻辑测试,只能够运行在模拟器中,我们刚创建的测试target正是前面一种。这也是为何选择真机时,会弹出上面错误提示的原因。 文档中提到了如何配置Application Tests的方法,但是很遗憾,因为这篇文档是针对旧的OCTest框架,现在Xcode采用了新的XCTest框架,所以已经是”Retired”状态: Retired Document Important: This version of Unit Testing Guide has been retired. The replacement document focuses on the new testing features and workflow provided by Xcode 5 and later revisions. For information covering the same subject area as this page, please see Testing with Xcode. 新的文档中也没有再提这两个概念。但由于XCTest的前身就是OCTest,是否配置的方法也是相通的?是否将测试target变成Application Tests之后,就可以运行在App环境中?抱着试一试的想法,按照废弃文档中的方法来配置测试target。 在General配置页面,里面有一个Host Application,这个便表示测试是否可以运行于App中。但由于当前测试的是一个静态库,无法选择想要运行的App,此时需要用通过其它途径来指定。在ECSDKTests的Build Settings中修改两处: Bundle Loader: Your/App/Path/ECApp.app/ECApp Test host: $(BUNDLE_LOADER) 再次运行,发现ECApp的应用先启动,随后测试用例开始执行。因为ECApp在启动之后便配置了App环境的标志位,所以环境依赖的测试用例可以正常通过,测试已经可以运行于App的环境中,我们的尝试成功了。现在只剩下最后一个场景,如何让测试运行于真机上: 让测试运行在真机上 其实,在完成上一步的配置之后,测试已经从所谓的Logic Tests就转变成了Application Tests,而后者对运行的环境是没有限制的。直接将Scheme设置成真机,先编译一下ECApp,再运行一次测试,所有的测试可以通过: 特殊情况 注意:事情并不会总是这么顺利,有时候由于一个App过于庞大,各个库的podspec写得不是很规范,不是所有依赖的Libraries都写在了podspec中,有些被放在Build Phases里面,系统库尤为常见。这样就导致即使我们按照前面介绍的都配置好了,还是无法让测试target编译通过,在链接时会出现各种各样的找不到符号的错误。此时需要手动去添加这些库到测试target的Build Phases中。至于需要添加哪些,只有根据编译时的错误逐一添加了。而且有一点需要注意:有时库的Status需要是Optional,否则最后链接的时候也会出错。下面是一个真实测试用例在Build Phases中所依赖的库: 它一共依赖了41个系统库,每一个都是在编译出错时,查到缺少的符号所在的库来添加的,是个体力活:-)。 结尾 至此,我们的测试用例已经可以运行于上面描述的几种典型的复杂环境,其实最重要的步骤只有两步,第一步是设置依赖,处理各种编译错误;第二步是设置Build Settings,将测试转成Application Tests,让测试能够运行于App环境。 我们搭建的环境和真实的环境相比起来,复杂度还存在一定的差距,在编译测试target时会出现各种各样奇怪的问题,本文无法一一例举,靠大家根据实际情况处理了。 如果想对Xcode的测试有一个系统的了解,强烈建议大家去阅读文档Testing with Xcode,非常详细的介绍了用Xcode进行测试的方方面面。 另外,推荐一个全面讲解单元测试的网站——GURU,里面提供了免费的教程,除了单元测试以外,还有很多其它的内容。 新的一年,以这篇简单的文章作为起始,祝大家新年快乐! (全文完) feihu 2016.02.17 于 Shenzhen

2016/2/17
articleCard.readMore

如何处理iOS中照片的方向

使用过iPhone或者iPad的朋友在拍照时不知是否遇到过这样的问题,将设备中的照片导出到Windows上时,经常发现导出的照片方向会有问题,要么横着,要么颠倒着,需要旋转才适合观看。而如果直接在这些设备上浏览时,照片会始终显示正确的方向,在Mac上也能正确显示。最近在iOS的开发中也遇到了同样的问题,将拍摄的照片上传到服务器后,再由Windows端下载该照片,发现手机上完全正常的照片到了这里显示的横七竖八。同一张照片为什么在不同的设备上表现的不同?如何能够避免这种情况?本文将和大家一一解开这些问题。 目录 照片的存储演变 胶片时代 数码时代 方向传感器 EXIF(Exchangeable Image File Format) Orientation iPhone上的情况 验证EXIF Mac平台 Windows平台 开发时如何避免 直观的解决方案 第二种简单的方法 结尾 照片的存储演变 一切都得从相机的发展开始说起。 胶片时代 一般相机拍摄出来的画面都是长方形,在拍摄的那一瞬间,它会将取景器中的场景对应的颜色值存到对应的像素位置。相机本身并没有任何方向的概念,只是使用者想要拍摄的场景在他期望的照片中显示的方式与实际存在差异时,才有了方向一说。如下图,对一个场景F进行拍摄,相机的方向可能会有这样四个常见的角度: 相机是“自私”的,由于相机仅反应真实的场景,它不理解拍摄的内容,因此照片都以相机的坐标系保存,于是上面四种情形实际拍摄出来的照片会像这样: 最初的卡片机时代,照片都会经由底片洗出来。那时不存在照片的方向问题,因为不管我们以何种角度拍摄,最终洗出来的照片,它本身非常容易旋转,所以我们总可以通过简单的旋转来观看照片或者保存照片。比如这张照片墙中的照片,你能否说哪些照片是横着?哪些颠倒着?你甚至都无法判断每张照片相机是以何种角度拍摄的,因为每张都已经旋转至适合观看的角度。 数码时代 可是到了数码时代,不再需要底片,照片需要被存成一个图像文件。对于上面的拍摄角度,存储方式并没有变化,所有的场景仍然是以相机的坐标系来保存。于是这些照片仍像上面一样,原封不动的保存了下来: 虽然存储方式不变,和卡机机时代的实体相片不同的是,由于电脑屏幕可没洗出来的照片那么容易旋转,所以照片只能够以它存储于磁盘中的方向来展示。这便是为何照片传到电脑上之后,会出现横了,或者颠倒的情况。正因为这样,我们只有利用工具来旋转照片才能够正常观看。 方向传感器 为了克服这一情况,让照片可以真实的反应人们拍摄时看到的场景,现在很多相机中就加入了方向传感器,它能够记录下拍摄时相机的方向,并将这一信息保存在照片中。照片的存储方式还是没有任何改变,它仍然是以相机的坐标系来保存,只是当相机来浏览这些照片时,相机可以根据照片中的方向信息,结合此时相机的方向,对照片进行旋转,从而转到适合人们观看的角度。 但是很遗憾,这一标准并没有被广泛的传播开来,或者说始终如一的贯彻,这也导致了本文所讨论的问题。 EXIF(Exchangeable Image File Format) 那么,方向信息到底是记录在照片的什么位置? 了解图像格式的朋友可能会知道,图像一般都由两大部分组成,一部分是数据本身,它记录了每个像素的颜色值,另外一部分是文件头,这里面记录着形如图像的宽度,高度等信息。我们所讨论的方向信息便是被存储于文件头中。更为具体一些:EXIF中,维基百科上对其的解释为: 可交换图像文件格式常被简称为Exif(Exchangeable image file format),是专门为数码相机的照片设定的,可以记录数码照片的属性信息和拍摄数据… Exif可以附加于JPEG、TIFF、RIFF等文件之中 注意:PNG格式的图像中不包含。 Orientation 在EXIF涵盖的各种信息之中,其中有一个叫做Orientation (rotation)的标签,用于记录图像的方向,这便是相机写入方向信息的最终位置。它总共定义了八个值: 注意:对于上面的八种方向中,加了*的并不常见,因为它们代表的是镜像方向,如果不做任何的处理,不管相机以任何角度拍摄,都无法出现镜像的情况。 这个表格代表什么意义?我们来看第一行,值为1时,右边两列的值分别为:Row #0 is Top,Column #0 is Left side,其实很好理解,它表示照片的第一行位于顶端,而第一列位于左侧,那么这张照片自然就是以正常角度拍摄的。 对着前面的四种拍摄角度,由于相机都是以其自身的坐标系来保存照片,因此每张照片对应的第一行和第一列的位置始终如下: 我们来看第二张照片,这张照片需要逆时针旋转90度才能够正常观看。旋转之后,它的第一行位于左侧,而第一列位于下侧。如此一来,对比表格,它的Orientation值为8。所以说,这个Orientation值提供了想要正常观看图像时应该旋转的方式。 以同样的方法,我们可以推断出上面四种方式拍摄时,对应EXIF中Orientation的值如下所示: 由于相机加上了方向传感器的缘故,可以非常容易的检测出以上几种拍摄角度,并将角度对应的Orientation值保存至图像中。查看图像时,相机检测到其EXIF中的Orientation信息,并将图像旋转相应的角度显示给用户,这样便达到了智能显示的目的。 iPhone上的情况 作为智能手机的重要组成部分,形形色色的传感器自然必不可少。在iOS的设备中也是包含了这样的方向传感器,它也采用了同样的方式来保存照片的方向信息到EXIF中。但是它默认的照片方向并不是竖着拿手机时的情况,而是横向,即Home键在右侧,如下: 如此一来,如果竖着拿手机拍摄时,就相当于对手机顺时针旋转了90度,也即上面相机图片中的最后一幅,那么它的Orientation值为6。 验证EXIF 在经过上面的分析之后,我们来看看实际情况如何。我们分别在Mac和Windows平台上对前面的论述做一个验证。 Mac平台 可以将照片从iOS设备中导出到Mac系统上,(注意,不能够使用iPhoto或者Photos来导入,因为这样照片在导入之前会被自动调整好方向)在这里我们像Windows中一样,将iPhone当成移动硬盘,直接访问其照片。在Mac上可以使用iTools这一神器。 然后用Mac上的预览程序查看其EXIF属性,通过预览-工具-显示检查器打开对话框,即可查看到照片中关于方向的详细信息。下面四张图分别展示了上面四种方向下拍得照片的Orientation值: Home键位于右侧时,即相机的默认方向,值为1。 Home键位于上侧时,值为8。 Home键位于左侧时,值为3。 Home键位于下侧时,即正常手持手机的方向,值为6。 对照前面的分析,完全一致。而且照片显示正常,说明在Mac上默认的预览程序会自动的处理EXIF中的Orientation信息。 再次提醒:照片存储在手机中始终是以相机坐标系保存的,只是浏览工作在读取方向信息之后做了旋转。 Windows平台 前面提到过,被写在图像文件头中的方向信息并没有被全部支持,Windows的照片查看器便是其中之一,这也是Windows用户最常使用的照片浏览工具。因为没有读取方向信息,照片被读入之后,完全按照其存储方式来显示,这样便出现了横向,或者颠倒的情况。下面四张图便分别是上一节中拍得的照片在Windows上的显示效果,注意看方向。 开发时如何避免 既然不是所有的工具都支持方向属性,这其中甚至包含了具有最多用户群体的Windows,那么我们在开发照片相关的应用时,有没有什么应对之策? 当然有!因为可以非常容易的得到照片的方向信息,那么只需要在保存之前将照片旋转至正常观看的方向即可,然后直接将最终具有正确方向的照片保存下来,搞定。 当我们得到一个UIImage对象时,它有一个属性叫:imageOrientation,这里面便保存了方向信息: Property 它刚好也可能为下面八种值,这些值可以和EXIF中Orientation的定义一一对应: UIImageOrientationUp UIImageOrientationDown UIImageOrientationLeft UIImageOrientationRight UIImageOrientationUpMirrored UIImageOrientationDownMirrored UIImageOrientationLeftMirrored UIImageOrientationRightMirrored 那么我们便可以根据这一属性对图像进行相应的旋转,从而将图像的原始数据旋转至正确的方向,在浏览照片时无需方向信息便可正常浏览。 关于如何旋转图像,StackOverflow上给出了很好的答案,比如这个。我们简单做一个介绍: 直观的解决方案 首先,为UIImage创建一个category,其中包含fixOrientation方法: UIImage+fixOrientation.h @interface UIImage (fixOrientation) - (UIImage *)fixOrientation; @end UIImage+fixOrientation.m @implementation UIImage (fixOrientation) - (UIImage *)fixOrientation { // No-op if the orientation is already correct if (self.imageOrientation == UIImageOrientationUp) return self; // We need to calculate the proper transformation to make the image upright. // We do it in 2 steps: Rotate if Left/Right/Down, and then flip if Mirrored. CGAffineTransform transform = CGAffineTransformIdentity; switch (self.imageOrientation) { case UIImageOrientationDown: case UIImageOrientationDownMirrored: transform = CGAffineTransformTranslate(transform, self.size.width, self.size.height); transform = CGAffineTransformRotate(transform, M_PI); break; case UIImageOrientationLeft: case UIImageOrientationLeftMirrored: transform = CGAffineTransformTranslate(transform, self.size.width, 0); transform = CGAffineTransformRotate(transform, M_PI_2); break; case UIImageOrientationRight: case UIImageOrientationRightMirrored: transform = CGAffineTransformTranslate(transform, 0, self.size.height); transform = CGAffineTransformRotate(transform, -M_PI_2); break; case UIImageOrientationUp: case UIImageOrientationUpMirrored: break; } switch (self.imageOrientation) { case UIImageOrientationUpMirrored: case UIImageOrientationDownMirrored: transform = CGAffineTransformTranslate(transform, self.size.width, 0); transform = CGAffineTransformScale(transform, -1, 1); break; case UIImageOrientationLeftMirrored: case UIImageOrientationRightMirrored: transform = CGAffineTransformTranslate(transform, self.size.height, 0); transform = CGAffineTransformScale(transform, -1, 1); break; case UIImageOrientationUp: case UIImageOrientationDown: case UIImageOrientationLeft: case UIImageOrientationRight: break; } // Now we draw the underlying CGImage into a new context, applying the transform // calculated above. CGContextRef ctx = CGBitmapContextCreate(NULL, self.size.width, self.size.height, CGImageGetBitsPerComponent(self.CGImage), 0, CGImageGetColorSpace(self.CGImage), CGImageGetBitmapInfo(self.CGImage)); CGContextConcatCTM(ctx, transform); switch (self.imageOrientation) { case UIImageOrientationLeft: case UIImageOrientationLeftMirrored: case UIImageOrientationRight: case UIImageOrientationRightMirrored: // Grr... CGContextDrawImage(ctx, CGRectMake(0,0,self.size.height,self.size.width), self.CGImage); break; default: CGContextDrawImage(ctx, CGRectMake(0,0,self.size.width,self.size.height), self.CGImage); break; } // And now we just create a new UIImage from the drawing context CGImageRef cgimg = CGBitmapContextCreateImage(ctx); UIImage *img = [UIImage imageWithCGImage:cgimg]; CGContextRelease(ctx); CGImageRelease(cgimg); return img; } @end 代码有些长,不过却非常直观。这里面涉及到图像矩阵变换的操作,理解起来可能稍稍有些困难,接下来,我会有另外一篇文章专门来介绍图像变换。现在,记住下面两点便能够很好的帮助理解: 图像的原点在左下角 矩阵变换时,后面的矩阵先作用,前面的矩阵后作用 以UIImageOrientationDown方向为例,,很明显它翻转了180度。那么对它的旋转需要两步,第一步是以左下方为原点旋转180度,(此时顺时针还是逆时针旋转效果一样)旋转后上图变为: 。用代码表示为: transform = CGAffineTransformRotate(transform, M_PI); 因为是以左下方为原点旋转的,所以整幅图被移到了第三象限。第二步需要将其平移至第一象限,向右上方进行平移即可。x方向上移动距离为图像的宽度,y方向上移动距离为图像的高度,所以平移后图像变为:。代码为: transform = CGAffineTransformTranslate(transform, self.size.width, self.size.height); 再加上我们前面所说的第二点,矩阵变换时,后面的矩阵先作用,前面的矩阵后作用,那么只需要将上面两步颠倒即可: transform = CGAffineTransformTranslate(transform, self.size.width, self.size.height); transform = CGAffineTransformRotate(transform, M_PI); 其它的方向可以用完全一样的方法来分析,这里不再一一赘述。 第二种简单的方法 第二种方法同样也是StackOverflow上的答案,没那么直观,但非常简单: - (UIImage *)normalizedImage { if (self.imageOrientation == UIImageOrientationUp) return self; UIGraphicsBeginImageContextWithOptions(self.size, NO, self.scale); [self drawInRect:(CGRect){0, 0, self.size}]; UIImage *normalizedImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return normalizedImage; } 这里是利用了UIImage中的drawInRect方法,它会将图像绘制到画布上,并且已经考虑好了图像的方向,开发文档这样解释: -drawInRect: Discussion 结尾 关于照片方向的处理就介绍到这里,相信看完本文你已经知悉为何以及如何处理这个问题。 关于EXIF,这里面包含了很多有趣的内容,比如iPhone拍摄后,可以记录当时的GPS位置,这样在查看照片的时候就可以很神奇的知道照片的拍摄地。如果感兴趣可以去一探究竟。 另外,除去专门的照片浏览工具,所有的现代浏览器也天生具备查看图片的功能。而且有很多浏览器也已经支持EXIF中的Orientation,比如Firefox, Chrome, Safari。但同样很可惜,IE并不支持(一直到IE9.0尚不支持)。也许和Win7设计时并没有这些具有方向传感器的手机有关,我从网上了解到,在当初2012年收集building Windows8意见时,就有人提到过这一问题,希望能够考虑图片的方向信息,微软也给出了回应: (In Windows8)Explorer now respects EXIF orientation information for JPEG images. If your camera sets this value accurately, you will rarely need to correct orientation. 但我一直没有用过Windows8,如果有使用过的,希望可以帮我验证一下是否微软已经修复这个问题。 (全文完) feihu 2015.05.31 于 Shenzhen

2015/5/31
articleCard.readMore

跳出手掌心——如何立即触发UIButton边界事件

最近在使用UIButton的过程中遇到一个问题,我想要获得手指拖动button并离开button边界时的回调,于是监听UIControlEventTouchDragExit事件,如文档所述: An event where a finger is dragged from within a control to outside its bounds. 这个事件正是我所需要的,可是最后却发现当手指离开button边界时,事件并没有触发,而是到了远离button近70个像素时才收到回调。 目录 来自StackOverflow的答案 检验结果 换个思路 注册回调 回调函数 处理TouchUp事件 结尾 为了更好的说明问题,我做了一个示例,见下图。所期待的行为是:当手指离开button边界时会将button的内容改为离开,进入时改为进入。另外在手指的位置给出手指距离button最上端的像素差。 但是,当手指离开button边界时,button的内容并没有改变。而当手指距离button顶端70像素时才变为离开。由此可以看出,UIControlEventTouchDragExit事件并不是在离开button边界时立刻触发,而是在距button顶端70像素时才会。 在这里我只是演示了手指向上移动的情况,其实向另外三个方向移动时,也会有一样的效果,有兴趣的同学可以自己尝试一番。 而且并不仅仅是UIControlEventTouchDragExit这一个事件,所有与边界有关的事件都有这一问题: UIControlEventTouchDragInside UIControlEventTouchDragOutside UIControlEventTouchDragEnter UIControlEventTouchDragExit UIControlEventTouchUpInside UIControlEventTouchUpOutside 不知道苹果为什么要这样设定,一直没有查到相关的资料。猜测可能是苹果觉得人的手指比较粗,和屏幕的接触面积比较大,定位也不需要那么精准,所以设定了一个这么大的外部区域吧。 但是很多情况下,如果我们需要更为精确的控制时,这70个像素的扩张就不行了。那么有没有办法能够更快的跳出button的手掌心呢? 来自StackOverflow的答案 经过一番查找,在StackOverflow上面找到了一个答案,它是通过覆盖UIControl的continueTrackingWithTouch:withEvent方法,由于UIButton是派生自UIControl,因此也继承了此方法。先来看看它的声明: - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event /* Description Sent continuously to the control as it tracks a touch related to the given event within the control’s bounds. Parameters touch A UITouch object that represents a touch on the receiving control during tracking. event An event object encapsulating the information specific to the user event Returns YES if touch tracking should continue; otherwise NO. */ 这个方法判断是否保持追踪当前的触摸事件。这里根据得到的位置来判断是否正处于button的范围内,进而发送对应的事件。相应的代码为: - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { CGFloat boundsExtension = 25.0f; CGRect outerBounds = CGRectInset(self.bounds, -1 * boundsExtension, -1 * boundsExtension); BOOL touchOutside = !CGRectContainsPoint(outerBounds, [touch locationInView:self]); if(touchOutside) { BOOL previousTouchInside = CGRectContainsPoint(outerBounds, [touch previousLocationInView:self]); if(previousTouchInside) { NSLog(@"Sending UIControlEventTouchDragExit"); [self sendActionsForControlEvents:UIControlEventTouchDragExit]; } else { NSLog(@"Sending UIControlEventTouchDragOutside"); [self sendActionsForControlEvents:UIControlEventTouchDragOutside]; } } return [super continueTrackingWithTouch:touch withEvent:event]; } 在代码中,boundsExtension设置为25,它便是对应着前面所讨论的70,即button“手掌心”的范围。当然我们可以将它设置为其它任何值。 检验结果 这个方法看起来非常好,也被原问题采纳为正确答案。但在尝试之后,我发现它有两个严重的问题: UIControlEventTouchDragExit会响应两次,分别为: 手指离开button边界25个像素时触发 第二次依然是70个像素时触发,这是UIButton的默认行为 第二个问题是在事件的回调函数: - (void)callback:(UIButton *)sender withEvent:(UIEvent *)event 中,由UIEvent参数计算得到的位置始终是(0, 0),它并未正确的初始化 仔细一想便能理解,在覆盖的函数中我们进行判断之后触发了对应的事件,但这并没有取消原来UIControl本应该触发的事件,这便导致了两次响应;并且在我们的处理中,仅仅只是触发了事件,这里并没有涉及到UIEvent的初始化工作,因此最后得到的位置肯定不对了。 对于重复响应的问题,有人可能会猜,会不会上面最后一行调用父类方法有影响: return [super continueTrackingWithTouch:touch withEvent:event]; 我后来也尝试过,直接在结尾返回YES,上面的问题仍然存在,可见并不是它的缘故。 换个思路 由于上面两个问题的缘故,这个答案不可取。那还有别的办法么? 我们来仔细观察前面的方法,用前半部分的代码,可以很容易的判断出当前位置是否位于button之内。那么我们是否可以不在底层处理,而是在上层的回调函数中去判断?基于这一思路,我又做了这样的尝试: 注册回调 // to get the drag event [btn addTarget:self action:@selector(btnDragged:withEvent:) forControlEvents:UIControlEventTouchDragInside]; [btn addTarget:self action:@selector(btnDragged:withEvent:) forControlEvents:UIControlEventTouchDragOutside]; 第一步仍然是注册回调函数,但是注意看,这里两个事件注册的是同一个回调函数btnDragged:withEvent:。而且并没有注册UIControlEventTouchDragExit和UIControlEventTouchDragEnter,取而代之的是UIControlEventTouchDragInside和UIControlEventTouchDragOutside,为什么?请接着向下看。 回调函数 回调函数里面采用了前面答案中的判断方法,可以根据当前和之前的位置判断出是否在button内部。然后就可以判断出此时到底属于哪一个事件,如下面的注释所示。至此,我们便可以在每一个分支中做对应的处理了。 - (void)btnDragged:(UIButton *)sender withEvent:(UIEvent *)event { UITouch *touch = [[event allTouches] anyObject]; CGFloat boundsExtension = 25.0f; CGRect outerBounds = CGRectInset(sender.bounds, -1 * boundsExtension, -1 * boundsExtension); BOOL touchOutside = !CGRectContainsPoint(outerBounds, [touch locationInView:sender]); if (touchOutside) { BOOL previewTouchInside = CGRectContainsPoint(outerBounds, [touch previousLocationInView:sender]); if (previewTouchInside) { // UIControlEventTouchDragExit } else { // UIControlEventTouchDragOutside } } else { BOOL previewTouchOutside = !CGRectContainsPoint(outerBounds, [touch previousLocationInView:sender]); if (previewTouchOutside) { // UIControlEventTouchDragEnter } else { // UIControlEventTouchDragInside } } } 注意看,这里我们仅仅通过注册两个事件,却达到了相当于四个事件的效果。最后的效果如下,这里依然是设置了boundsExtension为25,当然你可以设置成任意你想要的值。 处理TouchUp事件 在本文开头我们提到过,所有需要判断是否在button内部的事件都有这个问题,如UIControlEventTouchUpInside和UIControlEventTouchUpOutside,当然也可以使用同样的办法来处理: 先为两个事件注册同一个回调函数: // to get the touch up event [btn addTarget:self action:@selector(btnTouchUp:withEvent:) forControlEvents:UIControlEventTouchUpInside]; [btn addTarget:self action:@selector(btnTouchUp:withEvent:) forControlEvents:UIControlEventTouchUpOutside]; 然后处理回调函数: - (void)btnTouchUp:(UIButton *)sender withEvent:(UIEvent *)event { UITouch *touch = [[event allTouches] anyObject]; CGFloat boundsExtension = 25.0f; CGRect outerBounds = CGRectInset(sender.bounds, -1 * boundsExtension, -1 * boundsExtension); BOOL touchOutside = !CGRectContainsPoint(outerBounds, [touch locationInView:sender]); if (touchOutside) { // UIControlEventTouchUpOutside } else { // UIControlEventTouchUpInside } } 结尾 因为UIButton的addTarget:action:forControlEvents方法是继承自UIControl,因此上面的办法对于所有UIControl的子类都同样适用,比如UISwitch,UISlider等等。 我也在StackOverflow原来的问题上作了补充。如果你有更好的办法,或者知道为何苹果如此处理,请给我留言或者在原问题上回答。 (全文完) feihu 2015.05.21 于 Shenzhen

2015/5/21
articleCard.readMore

知无涯之回车换行的故事

不知各位有没有过这样的经历: Linux上创建的文件在Windows上打开时,结果所有内容会挤成一行。而Windows上创建的文件在Linux上打开时,每一行的结尾又多了一个奇怪字符^M。 在安装Windows版的git时,安装向导在某一步会提示你选择”Configuring the line ending conversions”,里面提到了Windows-style和unix-style的line endings,为什么会有这些呢? 调用C语言的API fopen时,会有text mode和binary mode,这两者有什么区别? 其实这一切都和我们常说的回车换行有关,但你有没有很奇怪,什么是回车?直接用换行不就好了,为什么要分开两个词?我们使用的键盘上的键明明起得是换行的作用,为什么叫回车?千万别被绕晕了,本文将和大家讨论有关回车换行的一段有趣的历史,随后将回答这些问题。 目录 历史 打字机 分歧出现 混乱的状况 统一 Text Mode VS Binary Mode Windows平台 Linux和Mac OSX平台 更多资料 结尾 历史 我们通常所说的回车换行其实只相当于一个概念,即一行结束,开始下一行,英文叫做End-of-Line,简写为EOL。你也可以将这理解为一个逻辑上的换行,但为了与回车换行中的换行区分开来,我们后面还是称呼它为EOL。 打字机 回车换行严格说起来是两个独立的概念,即回车和换行,它们的出现要追溯到计算机出现之前,那时有一种电传打字机:Teletype Model 33 ASR,如下图: 在打字机上,有一个部件叫Carriage,它是打字头,相当于打字机的光标。每输入一个字符,Carriage就前进一格。当输满一行后,想要切换到下一行时,需要Carriage在两个方向上的运动:水平和竖直。水平方向上需要将Carriage移到一行的起始位置,竖直方向上需要纸张向上移动一行,此时也就是相当于Carriage下移了一行。(这在很多影视作品里面可以看到,打字者们打完一行之后,通常会用手拨动一个滑块,然后听到“咔”的一声,接着输入下一行。只是在这款打字机中不再需要人为的去拨动。)而这两个动作分别对应着: Carriage Return(CR),也即回车,它在ASCII表中的值为0x0D,可以用转义符\r表示 Line Feed(LF),也即换行,它在ASCII表中的值为0x0A,可以用转义符\n表示 因为打字机是机械的结构,所以虽然从逻辑上只表示为EOF,但从设计上它需要分为两个独立的操作,这也正是我们习惯连起来说回车换行的原因。可以参照下图看看其键盘的布局: 键盘的右方有一个Line Feed和Return,从名字可以看出,这分别对应着前面提到的两个操作。然而,通常一个回车操作不能够在一个字符打印的时间内完成,所以可以利用Carriage移动的时间,去完成另外一个完全独立的操作Line Feed,这也是通常Carriage Return会被放在Line Feed前面的原因。你可以想象,如果在在Carriage和纸移动的过程中按下了其它的字符键,打印的内容将变得十分混乱。所以在Carriage Return和Line Feed之后,有时会有1~3个NUL字符(即相当于汇编语言中的空指令,仅起占位作用),以等待前两个操作的完成。所以实际上打字机的EOL为:EOL = CR + LF + 1~3NUL。 分歧出现 等到早期的计算机发明时,很自然的这两个概念被拿了过来。但是由于那时的存储设备非常昂贵,一些人认为在每行的结尾加两个字符用于换行,实在是极大的浪费,于是各个厂商在这一点上便出现了分歧。 由于一些早期的微型计算机还没有用于隐藏底层硬件细节的设备驱动,所以它们直接沿用了打字机的惯例,使用不带NUL的CRLF作为一个EOL。而CP/M为了和这些微型计算机使用同一个终端,也采用了这种设计。所以它的克隆MS-DOS也同样使用CRLF,由于Windows又是基于MS-DOS,为保持兼容性,所以就导致了如今的Windows是采用CRLF作为EOL,即\r\n(或0x0D 0x0A)。 而Multics在被设计之时就非常认真的考虑了这一问题,设计者们觉得只需一个字符便完全足够来表示EOL,这样更加合理。那么选择CR还是LF呢?本来由于那时的键盘上都有一个Return键,所以可能更好的选择是CR。但当时考虑到CR可以用来重写一行,以完成如粗体和删除线等效果,所以他们选择了稍稍难以理解的LF。然后自己设计了一个设备驱动程序来将LF转换为各种打字机所需要的EOL,这个方案非常完美,当然除了LF稍微奇怪一些。随后一脉相承的Unix和Linux们都继承了这个选择,于是你在这些操作系统上可以发现每一行的结尾是一个LF,即\n(或0x0A)。 Mac系统的选择就更加复杂一些。Apple在设计Mac OS时,他们采用了一个最容易理解的选择:CR,即\r(或0x0D)。但这只维持到Mac OS 9,后一个版本的Mac OSX基于Mach-BSD内核,所以此后版本的Mac OSX在每行的结尾存储了与Linux一样的LF,即\n(或0x0A)。 混乱的状况 还有很多其它的操作系统采用更加不同的方案,这也导致了混乱的产生,文章开始提出的几个问题便由该混乱引起。因为Linux和Mac OSX上使用的是LF,而Windows上使用的是CRLF,那么Linux和Mac OSX上创建的文件在Windows上打开时,由于每一行的结尾只有一个LF,但Windows只认识CRLF,所以便不会有逻辑上的换行处理,故所有的文字被挤到了一行。反过来,如果Windows上的文件在Linux和Mac OSX上打开时,仅需LF便可换行,那么每一行的结尾便多了一个CR,对应的ASCII码为^M。 而git的安装向导会特意有一个这样的提醒页面也出于此,因为一个项目可能有多个开发者,每个开发者可能使用的是不同的系统,那么开发者checkout代码时,如果不做换行符的转换,有可能就会出现只有一行或者行尾多了^M的情况。当然,如果你有一个可以识别多种EOL的现代文本编辑器,那么不做转换也无妨(notepad不行)。 如果出现了上面的转换问题时,也别着急,可以对文件进行转换。那在我们写程序时如何正确的处理这些问题?像隐藏硬件细节的驱动程序一样,我们可寄希望于高级语言。 统一 为了避免在这些不同的实现中挣扎,高级语言给我们带来了福音,它们各自使用了统一的方式来处理EOL。在C语言中,你一定知道在字符串中如果要增加一个换行符的话,直接用\n即可,比如: printf("This is the first line! \nThis is a new line!"); 上面的输出将是: This is the first line! This is a new line! 为什么C语言选择了\n而不是\r?这绝非偶然。熟悉C语言历史的朋友可能知道当初C语言是Dennis Ritchie为开发Unix而设计,所以它沿用了Unix上EOL的惯例便很容易理解了。而我们知道Unix使用的LF的ASCII码为0x0A,转义符为\n,因此C语言中也使用\n作为换行。 Text Mode VS Binary Mode 但是,千万别简单的认为上面的\n最终写到文件中就一定是其ASCII码0x0A,或者文件中的0x0A被读到内存中就是其转义符\n。这取决于你打开文件的方式。在C语言中,在对文件进行读取操作之前,都需要先打开文件,可以使用下面的函数: #inlcude <stdio.h> FILE *fopen(const char *path, const char *mode); 注意看第二个参数mode,它是一个字符指针,通常可以为读(r),写(w),追加(a)或者读写(r+, w+, a+),仅指定这些参数时,文件将被当成是文本文件来操作,即Text Mode,而如果在这些参数之外再指定一个额外的b时,文件便会被当成是二进制文件,即Binary Mode。这两种模式的区别在哪里呢?这里稍稍有些复杂,因为它们在不同的平台上表现不同。 Windows平台 对于Windows平台,因为其使用CRLF来表示EOL,故对于Text Mode需要做一定的转换才能够与C语言保持一致。接下来的两个图可以给出最为直观的描述。 先看二者对于读操作的区别: Text Mode下,C语言会尝试去“理解”这些回车与换行,它会知道LF和CRLF都可能是EOL,所以不管文件中是LF还是CRLF,被读进内存时都会变成LF。而Binary Mode下,C语言不会做任何的“理解”,所以这些字符在文件中什么样,读到内存中依然那样。 接下来是写操作的区别: Text Mode下,内存中的每一个LF写入文件中时都会变为CRLF,当然,如果不幸内存中为CRLF,以此种模式写入到文件中时就会变成CRCRLF(注意:这里不是CRLF。原因我想大概是如果你认为内存中的数据是文本,那么它一定是以LF作为EOL,CR也一定是你有意而为之,是个有意义的字符,所以它并不会处理。)。而Binary Mode下,内存中的内容会被原封不动的写到文件中。 所以为了保证一致性,一定需要注意配套使用读和写,即读和写采用同一种模式打开文件。 Linux和Mac OSX平台 因为Linux和Mac OSX平台与C语言对待EOL的方式完全一致,所以Text Mode和Binary Mode在这些平台下没有任何区别,可以参考fopen的man page。实际上,所有遵循POSIX的平台都忽略了b这个参数。 虽说在这些平台上处理EOL非常简单,但是如果你的程序需要移植到其它非POSIX平台上时,请务必正确对待b参数。 更多资料 如果还有兴趣,可以看看下面这些有趣的资料: 阮一峰的《回车与换行》 The End of Line Puzzle,也即上面那篇文章的出处 关于What is the ASCII code for newline character?的一个回答 维基百科上关于Newline的解释 从网络的角度讲述了End-of-Line的故事 打字机的一段视频,需梯子 结尾 这样一个小小的EOL便如此复杂,给人们带来了极大的困扰,但就如我在知无涯之C++ typename的起源与用法最后讨论过的一样,这个决定是经历过无数决断、波折与妥协才有了现在的结果。你可以选择保守,为向后兼容而作出妥协,那么你得面对不断累加的“不完美”,甚至“丑陋”的设计;你也可大胆尝试,破旧立新,牺牲向后兼容换取进步,那你也许得忍受人们的“唾骂”,或许还需承担被人们抛弃的风险。如何在这之间作出选择,没有明确的答案,恐怕一切就只有靠自己去判断了吧。 (全文完) feihu 2014.12.17 于 Shenzhen

2014/12/17
articleCard.readMore

ssh连接远程主机执行脚本的环境变量问题

近日在使用ssh命令ssh user@remote ~/myscript.sh登陆到远程机器remote上执行脚本时,遇到一个奇怪的问题: ~/myscript.sh: line n: app: command not found app是一个新安装的程序,安装路径明明已通过/etc/profile配置文件加到环境变量中,但这里为何会找不到?如果直接登陆机器remote并执行~/myscript.sh时,app程序可以找到并顺利执行。但为什么使用了ssh远程执行同样的脚本就出错了呢?两种方式执行脚本到底有何不同?如果你也心存疑问,请跟随我一起来展开分析。 目录 问题定位 bash的四种模式 interactive + login shell 加载配置文件 配置文件的意义 non-interactive + login shell interactive + non-login shell 加载配置文件 bashrc VS profile non-interactive + non-login shell 加载配置文件 更为直观的示图 典型模式总结 再次尝试 配置文件建议 写在结尾 说明,本文所使用的机器是:SUSE Linux Enterprise。 问题定位 这看起来像是环境变量引起的问题,为了证实这一猜想,我在这条命令之前加了一句:which app,来查看app的安装路径。在remote本机上执行脚本时,它会打印出app正确的安装路径。但再次用ssh来执行时,却遇到下面的错误: which: no app in (/usr/bin:/bin:/usr/sbin:/sbin) 这很奇怪,怎么括号中的环境变量没有了app程序的安装路径?不是已通过/etc/profile设置到PATH中了?再次在脚本中加入echo $PATH并以ssh执行,这才发现,环境变量仍是系统初始化时的结果: /usr/bin:/bin:/usr/sbin:/sbin 这证明/etc/profile根本没有被调用。为什么?是ssh命令的问题么? 随后我又尝试了将上面的ssh分解成下面两步: user@local > ssh user@remote # 先远程登陆到remote上 user@remote> ~/myscript.sh # 然后在返回的shell中执行脚本 结果竟然成功了。那么ssh以这两种方式执行的命令有何不同?带着这个问题去查询了man ssh: If command is specified, it is executed on the remote host instead of a login shell. 这说明在指定命令的情况下,命令会在远程主机上执行,返回结果后退出。而未指定时,ssh会直接返回一个登陆的shell。但到这里还是无法理解,直接在远程主机上执行和在返回的登陆shell中执行有什么区别?即使在远程主机上执行不也是通过shell来执行的么?难道是这两种方式使用的shell有什么不同? 暂时还没有头绪,但隐隐感到应该与shell有关。因为我通常使用的是bash,所以又去查询了man bash,才得到了答案。 bash的四种模式 在man page的INVOCATION一节讲述了bash的四种模式,bash会依据这四种模式而选择加载不同的配置文件,而且加载的顺序也有所不同。本文ssh问题的答案就存在于这几种模式当中,所以在我们揭开谜底之前先来分析这些模式。 interactive + login shell 第一种模式是交互式的登陆shell,这里面有两个概念需要解释:interactive和login: login故名思义,即登陆,login shell是指用户以非图形化界面或者以ssh登陆到机器上时获得的第一个shell,简单些说就是需要输入用户名和密码的shell。因此通常不管以何种方式登陆机器后用户获得的第一个shell就是login shell。 interactive意为交互式,这也很好理解,interactive shell会有一个输入提示符,并且它的标准输入、输出和错误输出都会显示在控制台上。所以一般来说只要是需要用户交互的,即一个命令一个命令的输入的shell都是interactive shell。而如果无需用户交互,它便是non-interactive shell。通常来说如bash script.sh此类执行脚本的命令就会启动一个non-interactive shell,它不需要与用户进行交互,执行完后它便会退出创建的shell。 那么此模式最简单的两个例子为: 用户直接登陆到机器获得的第一个shell 用户使用ssh user@remote获得的shell 加载配置文件 这种模式下,shell首先加载/etc/profile,然后再尝试依次去加载下列三个配置文件之一,一旦找到其中一个便不再接着寻找: ~/.bash_profile ~/.bash_login ~/.profile 下面给出这个加载过程的伪代码: execute /etc/profile IF ~/.bash_profile exists THEN execute ~/.bash_profile ELSE IF ~/.bash_login exist THEN execute ~/.bash_login ELSE IF ~/.profile exist THEN execute ~/.profile END IF END IF END IF 为了验证这个过程,我们来做一些测试。首先设计每个配置文件的内容如下: user@remote > cat /etc/profile echo @ /etc/profile user@remote > cat ~/.bash_profile echo @ ~/.bash_profile user@remote > cat ~/.bash_login echo @ ~/.bash_login user@remote > cat ~/.profile echo @ ~/.profile 然后打开一个login shell,注意,为方便起见,这里使用bash -l命令,它会打开一个login shell,在man bash中可以看到此参数的解释: -l Make bash act as if it had been invoked as a login shell 进入这个新的login shell,便会得到以下输出: @ /etc/profile @ /home/user/.bash_profile 果然与文档一致,bash首先会加载全局的配置文件/etc/profile,然后去查找~/.bash_profile,因为其已经存在,所以剩下的两个文件不再会被查找。 接下来移除~/.bash_profile,启动login shell得到结果如下: @ /etc/profile @ /home/user/.bash_login 因为没有了~/.bash_profile的屏蔽,所以~/.bash_login被加载,但最后一个~/.profile仍被忽略。 再次移除~/.bash_login,启动login shell的输出结果为: @ /etc/profile @ /home/user/.profile ~/.profile终于熬出头,得见天日。通过以上三个实验,配置文件的加载过程得到了验证,除去/etc/profile首先被加载外,其余三个文件的加载顺序为:~/.bash_profile > ~/.bash_login > ~/.profile,只要找到一个便终止查找。 前面说过,使用ssh也会得到一个login shell,所以如果在另外一台机器上运行ssh user@remote时,也会得到上面一样的结论。 配置文件的意义 那么,为什么bash要弄得这么复杂?每个配置文件存在的意义是什么? /etc/profile很好理解,它是一个全局的配置文件。后面三个位于用户主目录中的配置文件都针对用户个人,也许你会问为什么要有这么多,只用一个~/.profile不好么?究竟每个文件有什么意义呢?这是个好问题。 Cameron Newham和Bill Rosenblatt在他们的著作《Learning the bash Shell, 2nd Edition》的59页解释了原因: bash allows two synonyms for .bash_profile: .bash_login, derived from the C shell’s file named .login, and .profile, derived from the Bourne shell and Korn shell files named .profile. Only one of these three is read when you log in. If .bash_profile doesn’t exist in your home directory, then bash will look for .bash_login. If that doesn’t exist it will look for .profile. One advantage of bash’s ability to look for either synonym is that you can retain your .profile if you have been using the Bourne shell. If you need to add bash-specific commands, you can put them in .bash_profile followed by the command source .profile. When you log in, all the bash-specific commands will be executed and bash will source .profile, executing the remaining commands. If you decide to switch to using the Bourne shell you don’t have to modify your existing files. A similar approach was intended for .bash_login and the C shell .login, but due to differences in the basic syntax of the shells, this is not a good idea. 原来一切都是为了兼容,这么设计是为了更好的应付在不同shell之间切换的场景。因为bash完全兼容Bourne shell,所以.bash_profile和.profile可以很好的处理bash和Bourne shell之间的切换。但是由于C shell和bash之间的基本语法存在着差异,作者认为引入.bash_login并不是个好主意。所以由此我们可以得出这样的最佳实践: 应该尽量杜绝使用.bash_login,如果已经创建,那么需要创建.bash_profile来屏蔽它被调用 .bash_profile适合放置bash的专属命令,可以在其最后读取.profile,如此一来,便可以很好的在Bourne shell和bash之间切换了 non-interactive + login shell 第二种模式的shell为non-interactive login shell,即非交互式的登陆shell,这种是不太常见的情况。一种创建此shell的方法为:bash -l script.sh,前面提到过-l参数是将shell作为一个login shell启动,而执行脚本又使它为non-interactive shell。 对于这种类型的shell,配置文件的加载与第一种完全一样,在此不再赘述。 interactive + non-login shell 第三种模式为交互式的非登陆shell,这种模式最常见的情况为在一个已有shell中运行bash,此时会打开一个交互式的shell,而因为不再需要登陆,因此不是login shell。 加载配置文件 对于此种情况,启动shell时会去查找并加载/etc/bash.bashrc和~/.bashrc文件。 为了进行验证,与第一种模式一样,设计各配置文件内容如下: user@remote > cat /etc/bash.bashrc echo @ /etc/bash.bashrc user@remote > cat ~/.bashrc echo @ ~/.bashrc 然后我们启动一个交互式的非登陆shell,直接运行bash即可,可以得到以下结果: @ /etc/bash.bashrc @ /home/user/.bashrc 由此非常容易的验证了结论。 bashrc VS profile 从刚引入的两个配置文件的存放路径可以很容易的判断,第一个文件是全局性的,第二个文件属于当前用户。在前面的模式当中,已经出现了几种配置文件,多数是以profile命名的,那么为什么这里又增加两个文件呢?这样不会增加复杂度么?我们来看看此处的文件和前面模式中的文件的区别。 首先看第一种模式中的profile类型文件,它是某个用户唯一的用来设置全局环境变量的地方, 因为用户可以有多个shell比如bash, sh, zsh等, 但像环境变量这种其实只需要在统一的一个地方初始化就可以, 而这个地方就是profile,所以启动一个login shell会加载此文件,后面由此shell中启动的新shell进程如bash,sh,zsh等都可以由login shell中继承环境变量等配置。 接下来看bashrc,其后缀rc的意思为Run Commands,由名字可以推断出,此处存放bash需要运行的命令,但注意,这些命令一般只用于交互式的shell,通常在这里会设置交互所需要的所有信息,比如bash的补全、alias、颜色、提示符等等。 所以可以看出,引入多种配置文件完全是为了更好的管理配置,每个文件各司其职,只做好自己的事情。 non-interactive + non-login shell 最后一种模式为非交互非登陆的shell,创建这种shell典型有两种方式: bash script.sh ssh user@remote command 这两种都是创建一个shell,执行完脚本之后便退出,不再需要与用户交互。 加载配置文件 对于这种模式而言,它会去寻找环境变量BASH_ENV,将变量的值作为文件名进行查找,如果找到便加载它。 同样,我们对其进行验证。首先,测试该环境变量未定义时配置文件的加载情况,这里需要一个测试脚本: user@remote > cat ~/script.sh echo Hello World 然后运行bash script.sh,将得到以下结果: Hello World 从输出结果可以得知,这个新启动的bash进程并没有加载前面提到的任何配置文件。接下来设置环境变量BASH_ENV: user@remote > export BASH_ENV=~/.bashrc 再次执行bash script.sh,结果为: @ /home/user/.bashrc Hello World 果然,~/.bashrc被加载,而它是由环境变量BASH_ENV设定的。 更为直观的示图 至此,四种模式下配置文件如何加载已经讲完,因为涉及的配置文件有些多,我们再以两个图来更为直观的进行描述: 第一张图来自这篇文章,bash的每种模式会读取其所在列的内容,首先执行A,然后是B,C。而B1,B2和B3表示只会执行第一个存在的文件: +----------------+--------+-----------+---------------+ | | login |interactive|non-interactive| | | |non-login |non-login | +----------------+--------+-----------+---------------+ |/etc/profile | A | | | +----------------+--------+-----------+---------------+ |/etc/bash.bashrc| | A | | +----------------+--------+-----------+---------------+ |~/.bashrc | | B | | +----------------+--------+-----------+---------------+ |~/.bash_profile | B1 | | | +----------------+--------+-----------+---------------+ |~/.bash_login | B2 | | | +----------------+--------+-----------+---------------+ |~/.profile | B3 | | | +----------------+--------+-----------+---------------+ |BASH_ENV | | | A | +----------------+--------+-----------+---------------+ 上图只给出了三种模式,原因是第一种login实际上已经包含了两种,因为这两种模式下对配置文件的加载是一致的。 另外一篇文章给出了一个更直观的图: 上图的情况稍稍复杂一些,因为它使用了几个关于配置文件的参数:--login,--rcfile,--noprofile,--norc,这些参数的引入会使配置文件的加载稍稍发生改变,不过总体来说,不影响我们前面的讨论,相信这张图不会给你带来更多的疑惑。 典型模式总结 为了更好的理清这几种模式,下面我们对一些典型的启动方式各属于什么模式进行一个总结: 登陆机器后的第一个shell:login + interactive 新启动一个shell进程,如运行bash:non-login + interactive 执行脚本,如bash script.sh:non-login + non-interactive 运行头部有如#!/usr/bin/env bash的可执行文件,如./executable:non-login + non-interactive 通过ssh登陆到远程主机:login + interactive 远程执行脚本,如ssh user@remote script.sh:non-login + non-interactive 远程执行脚本,同时请求控制台,如ssh user@remote -t 'echo $PWD':non-login + interactive 在图形化界面中打开terminal: Linux上: non-login + interactive Mac OS X上: login + interactive 相信你在理解了login和interactive的含义之后,应该会很容易对上面的启动方式进行归类。 再次尝试 在介绍完bash的这些模式之后,我们再回头来看文章开头的问题。ssh user@remote ~/myscript.sh属于哪一种模式?相信此时你可以非常轻松的回答出来:non-login + non-interactive。对于这种模式,bash会选择加载$BASH_ENV的值所对应的文件,所以为了让它加载/etc/profile,可以设定: user@remote > export BASH_ENV=/etc/profile 然后执行上面的命令,但是很遗憾,发现错误依旧存在。这是怎么回事?别着急,这并不是我们前面的介绍出错了。仔细查看之后才发现脚本myscript.sh的第一行为#!/usr/bin/env sh,注意看,它和前面提到的#!/usr/bin/env bash不一样,可能就是这里出了问题。我们先尝试把它改成#!/usr/bin/env bash,再次执行,错误果然消失了,这与我们前面的分析结果一致。 第一行的这个语句有什么用?设置成sh和bash有什么区别?带着这些疑问,再来查看man bash: If the program is a file beginning with #!, the remainder of the first line specifies an interpreter for the program. 它表示这个文件的解释器,即用什么程序来打开此文件,就好比Windows上双击一个文件时会以什么程序打开一样。因为这里不是bash,而是sh,那么我们前面讨论的都不复有效了,真糟糕。我们来看看这个sh的路径: user@remote > ll `which sh` lrwxrwxrwx 1 root root 9 Apr 25 2014 /usr/bin/sh -> /bin/bash 原来sh只是bash的一个软链接,既然如此,BASH_ENV应该是有效的啊,为何此处无效?还是回到man bash,同样在INVOCATION一节的下部看到了这样的说明: If bash is invoked with the name sh, it tries to mimic the startup behavior of historical versions of sh as closely as possible, while conforming to the POSIX standard as well. When invoked as an interactive login shell, or a non-interactive shell with the –login option, it first attempts to read and execute commands from /etc/profile and ~/.profile, in that order. The –noprofile option may be used to inhibit this behavior. When invoked as an interactive shell with the name sh, bash looks for the variable ENV, expands its value if it is defined, and uses the expanded value as the name of a file to read and execute. Since a shell invoked as sh does not attempt to read and execute commands from any other startup files, the –rcfile option has no effect. A non-interactive shell invoked with the name sh does not attempt to read any other startup files. When invoked as sh, bash enters posix mode after the startup files are read. 简而言之,当bash以是sh命启动时,即我们此处的情况,bash会尽可能的模仿sh,所以配置文件的加载变成了下面这样: interactive + login: 读取/etc/profile和~/.profile non-interactive + login: 同上 interactive + non-login: 读取ENV环境变量对应的文件 non-interactive + non-login: 不读取任何文件 这样便可以解释为什么出错了,因为这里属于non-interactive + non-login,所以bash不会读取任何文件,故而即使设置了BASH_ENV也不会起作用。所以为了解决问题,只需要把sh换成bash,再设置环境变量BASH_ENV即可。 另外,其实我们还可以设置参数到第一行的解释器中,如#!/bin/bash --login,如此一来,bash便会强制为login shell,所以/etc/profile也会被加载。相比上面那种方法,这种更为简单。 配置文件建议 回顾一下前面提到的所有配置文件,总共有以下几种: /etc/profile ~/.bash_profile ~/.bash_login ~/.profile /etc/bash.bashrc ~/.bashrc $BASH_ENV $ENV 不知你是否会有疑问,这么多的配置文件,究竟每个文件里面应该包含哪些配置,比如PATH应该在哪?提示符应该在哪配置?启动的程序应该在哪?等等。所以在文章的最后,我搜罗了一些最佳实践供各位参考。(这里只讨论属于用户个人的配置文件) ~/.bash_profile:应该尽可能的简单,通常会在最后加载.profile和.bashrc(注意顺序) ~/.bash_login:在前面讨论过,别用它 ~/.profile:此文件用于login shell,所有你想在整个用户会话期间都有效的内容都应该放置于此,比如启动进程,环境变量等 ~/.bashrc:只放置与bash有关的命令,所有与交互有关的命令都应该出现在此,比如bash的补全、alias、颜色、提示符等等。特别注意:别在这里输出任何内容(我们前面只是为了演示,别学我哈) 写在结尾 至此,我们详细的讨论完了bash的几种工作模式,并且给出了配置文件内容的建议。通过这些模式的介绍,本文开始遇到的问题也很容易的得到了解决。以前虽然一直使用bash,但真的不清楚里面包含了如此多的内容。同时感受到Linux的文档的确做得非常细致,在完全不需要其它安装包的情况下,你就可以得到一个非常完善的开发环境,这也曾是Eric S. Raymond在其著作《UNIX编程艺术》中提到的:UNIX天生是一个非常完善的开发机器。本文几乎所有的内容你都可以通过阅读man page得到。最后,希望在这样一个被妖魔化的特殊日子里,这篇文章能够为你带去一丝帮助。 (全文完) feihu 2014.11.11 于 Shenzhen

2014/11/11
articleCard.readMore

以普通用户启动的Vim如何保存需要root权限的文件

在Linux上工作的朋友很可能遇到过这样一种情况,当你用Vim编辑完一个文件时,运行:wq保存退出,突然蹦出一个错误: E45: 'readonly' option is set (add ! to override) 这表明文件是只读的,按照提示,加上!强制保存::w!,结果又一个错误出现: "readonly-file-name" E212: Can't open file for writing 文件明明存在,为何提示无法打开?这错误又代表什么呢?查看文档:help E212: For some reason the file you are writing to cannot be created or overwritten. The reason could be that you do not have permission to write in the directory or the file name is not valid. 原来是可能没有权限造成的。此时你才想起,这个文件需要root权限才能编辑,而当前登陆的只是普通用户,在编辑之前你忘了使用sudo来启动Vim,所以才保存失败。于是为了防止修改丢失,你只好先把它保存为另外一个临时文件temp-file-name,然后退出Vim,再运行sudo mv temp-file-name readonly-file-name覆盖原文件。 目录 解决方案 Vim中执行外部命令 命令的另一种表示形式 %的意义 tee的作用 命令执行之后 更简单的方案:映射 另一种思路 重定向的问题 重定向方案 写在结尾 但这样操作过于繁琐。而且如果只是想暂存此文件,还需要接着修改,则希望保留Vim的工作状态,比如编辑历史,buffer状态等等,该怎么办?能不能在不退出Vim的情况下获得root权限来保存这个文件? 解决方案 答案是可以,执行这样一条命令即可: :w !sudo tee % 接下来我们来分析这个命令为什么可以工作。首先查看文档:help :w,向下滚动一点可以看到: *:w_c* *:write_c* :[range]w[rite] [++opt] !{cmd} Execute {cmd} with [range] lines as standard input (note the space in front of the '!'). {cmd} is executed like with ":!{cmd}", any '!' is replaced with the previous command |:!|. The default [range] for the ":w" command is the whole buffer (1,$) 把这个使用方法对应前面的命令,如下所示: : w !sudo tee % | | | | :[range]w[rite] [++opt] !{cmd} 我们并未指定range,参见帮助文档最下面一行,当range未指定时,默认情况下是整个文件。此外,这里也没有指定opt。 Vim中执行外部命令 接下来是一个叹号!,它表示其后面部分是外部命令,即sudo tee %。文档中说的很清楚,这和直接执行:!{cmd}是一样的效果。后者的作用是打开shell执行一个命令,比如,运行:!ls,会显示当前工作目录下的所有文件,这非常有用,任何可以在shell中执行的命令都可以在不退出Vim的情况下运行,并且可以将结果读入到Vim中来。试想,如果你要在Vim中插入当前工作路径或者当前工作路径下的所有文件名,你可以运行: :r !pwd或:r !ls 此时所有的内容便被读入至Vim,而不需要退出Vim,执行命令,然后拷贝粘贴至Vim中。有了它,Vim可以自由的操作shell而无需退出。 命令的另一种表示形式 再看前面的文档: Execute {cmd} with [range] lines as standard input 所以实际上这个:w并未真的保存当前文件,就像执行:w new-file-name时,它将当前文件的内容保存到另外一个new-file-name的文件中,在这里它相当于一个另存为,而不是保存。它将当前文档的内容写到后面cmd的标准输入中,再来执行cmd,所以整个命令可以转换为一个具有相同功能的普通shell命令: $ cat readonly-file-name | sudo tee % 这样看起来”正常”些了。其中sudo很好理解,意为切换至root执行后面的命令,tee和%是什么呢? %的意义 我们先来看%,执行:help cmdline-special可以看到: In Ex commands, at places where a file name can be used, the following characters have a special meaning. These can also be used in the expression function expand() |expand()|. % Is replaced with the current file name. *:_%* *c_%* 在执行外部命令时,%会扩展成当前文件名,所以上述的cmd也就成了sudo tee readonly-file-name。此时整个命令即: $ cat readonly-file-name | sudo tee readonly-file-name 注意:在另外一个地方我们也经常用到%,没错,替换。但是那里%的作用不一样,执行:help :%查看文档: Line numbers may be specified with: *:range* *E14* *{address}* {number} an absolute line number ... % equal to 1,$ (the entire file) *:%* 在替换中,%的意义是代表整个文件,而不是文件名。所以对于命令:%s/old/new/g,它表示的是替换整篇文档中的old为new,而不是把文件名中的old换成new。 tee的作用 现在只剩一个难点: tee。它究竟有何用?维基百科上对其有一个详细的解释,你也可以查看man page。下面这幅图很形象的展示了tee是如何工作的: ls -l的输出经过管道传给了tee,后者做了两件事,首先拷贝一份数据到文件file.txt,同时再拷贝一份到其标准输出。数据再次经过管道传给less的标准输入,所以它在不影响原有管道的基础上对数据作了一份拷贝并保存到文件中。看上图中间部分,它很像大写的字母T,给数据流动增加了一个分支,tee的名字也由此而来。 现在上面的命令就容易理解了,tee将其标准输入中的内容写到了readonly-file-name中,从而达到了更新只读文件的目的。当然这里其实还有另外一半数据:tee的标准输出,但因为后面没有跟其它的命令,所以这份输出相当于被抛弃。当然也可以在后面补上> /dev/null,以显式的丢弃标准输出,但是这对整个操作没有影响,而且会增加输入的字符数,因此只需上述命令即可。 命令执行之后 运行完上述命令后,会出现下面的提示: W12: Warning: File "readonly-file-name" has changed and the buffer was changed in Vim as well See ":help W12" for more info. [O]K, (L)oad File: Vim提示文件更新,询问是确认还是重新加载文件。建议直接输入O,因为这样可以保留Vim的工作状态,比如编辑历史,buffer等,撤消等操作仍然可以继续。而如果选择L,文件会以全新的文件打开,所有的工作状态便丢失了,此时无法执行撤消,buffer中的内容也被清空。 更简单的方案:映射 上述方式非常完美的解决了文章开始提出的问题,但毕竟命令还是有些长,为了避免每次输入一长串的命令,可以将它映射为一个简单的命令加到.vimrc中: " Allow saving of files as sudo when I forgot to start vim using sudo. cmap w!! w !sudo tee > /dev/null % 这样,简单的运行:w!!即可。命令后半部分> /dev/null在前面已经解释过,作用为显式的丢掉标准输出的内容。 另一种思路 至此,一个比较完美但很tricky的方案已经完成。你可能会问,为什么不用下面这样更常见的命令呢?这不是更容易理解,更简单一些么? :w !sudo cat > % 重定向的问题 我们来分析一遍,像前面一样,它可以被转换为相同功能的shell命令: $ cat readonly-file-name | sudo cat > % 这条命令看起来一点问题没有,可一旦运行,又会出现另外一个错误: /bin/sh: readonly-file-name: Permission denied shell returned 1 这是怎么回事?不是明明加了sudo么,为什么还提示说没有权限?稍安勿躁,原因在于重定向,它是由shell执行的,在一切命令开始之前,shell便会执行重定向操作,所以重定向并未受sudo影响,而当前的shell本身也是以普通用户身份启动,也没有权限写此文件,因此便有了上面的错误。 重定向方案 这里介绍了几种解决重定向无权限错误的方法,当然除了tee方案以外,还有一种比较方便的方案:以sudo打开一个shell,然后在该具有root权限的shell中执行含重定向的命令,如: :w !sudo sh -c 'cat > %' 可是这样执行时,由于单引号的存在,所以在Vim中%并不会展开,它被原封不动的传给了shell,而在shell中,一个单独的%相当于nil,所以文件被重定向到了nil,所有内容丢失,保存文件失败。 既然是由于%没有展开导致的错误,那么试着将单引号'换成双引号"再试一次: :w !sudo sh -c "cat > %" 成功!这是因为在将命令传到shell去之前,%已经被扩展为当前的文件名。有关单引号和双引号的区别可以参考这里,简单的说就是单引号会将其内部的内容原封不动的传给命令,但是双引号会展开一些内容,比如变量,转义字符等。 当然,也可以像前面一样将它映射为一个简单的命令并添加到.vimrc中: " Allow saving of files as sudo when I forgot to start vim using sudo. cmap w!! w !sudo sh -c "cat > %" 注意:这里不再需要把输出重定向到/dev/null中。 写在结尾 至此,借助Vim强大的灵活性,实现了两种方案,可以在以普通用户启动的Vim中保存需root权限的文件。两者的原理类似,都是利用了Vim可以执行外部命令这一特性,区别在于使用不同的shell命令。如果你还有其它的方案,欢迎给我留言。 (全文完) feihu 2014.07.30 于 Shenzhen

2014/7/30
articleCard.readMore

NBA 38大催泪瞬间:假如

此篇文章无关乎技术!献给热爱篮球的朋友! 假如 前段日子逛虎扑时看到一帖子,标题叫假如,进去之后看到一组NBA老照片。刚开始感到有些莫名其妙,为何放一大堆图在这里,和标题假如有什么关系?第一印象判定其为哗众取宠的帖子,可再往下来看到楼主自己的回复,他贴了信乐团的《假如》和歌词,但为何一句话没有,只贴了这些?难道和前面的照片有关联?怀着好奇,打算再看一遍。于是戴上耳机,点击播放,很自然随着节奏一句歌词对应着前面的一张图片。一瞬间,原本看似无厘头的每张图好似立刻被赋予了生命,伴着音乐述说着各自背后的故事,而每句歌词是点睛之笔,恰到好处的给了每段故事一个最好的诠释。一遍看下来,无数尘封的记忆被唤醒,不知不觉泪水模糊了眼眶。 看完后心情久久无法平复,关注篮球十余年,每张照片都可以勾起一段回忆。但可惜的是楼主没有做成视频,如果像我刚开始一样,点进来看之后发现一堆乱七八糟的照片,就判定为水贴,错过了该有多可惜,我相信一定有很多这样的人。这么好的内容应该以更好的形式展示出来,而并非手动的去拖动页面,播放音乐,竖起耳朵聆听歌词,它们应该是作为一个整体出现,而可以展示这一整体的最好方式便是视频。于是下定决心,花了两天时间将它制作出来,放上虎扑。除增加了前奏时96黄金一代合影的照片外,所有的内容均来自原贴。 这里便是最终的成品: 碎碎念 帖子发布后的前几天反应很平淡,并未达到预期的效果,虽有不甘,但也慢慢的被淡忘。没想到三天之后,老弟突然发了张截图给我: 帖子竟然意外登上虎扑首页,令我有些错谔,因为来得太突然,毫无征兆。首页的帖子改了标题,甚至一度我都不敢确信是同一个,直到看见虎扑的消息量陡增之后才得以接受。 关于平台 一瞬间帖子的浏览量涨了一百倍,难以想象,第一次让我切身的感受到平台的强大力量。人们常说,是金子在哪里都可以发光,这话到底是不是一种自我安慰的阿Q心态,我不得而知。但是,在这样一个信息爆炸的年代,即使再好的东西,如果位于一个不起眼的位置,受不到足够的关注,那么很容易被淹没在信息汪洋之中,最后为人们所忽略。而一旦给了它一个展示自己的平台,受到足够的关注,它便可以最大限度的展现自己,绽放光芒。 最好的例子便是《Flappy Bird》,这样一个火爆全球的游戏,其实早在去年5月就上架苹果App Store,一开始它只是无数默默无名的游戏中的一员,被人遗忘在某个偏远的角落里。沉寂半年之后,去年11月17日,著名宅男论坛Reddit里的一名用户在一个“死了又死的iOS自虐游戏名单”里提到了它并推荐出来,让它莫名其妙暴得大名。一个多月后,Flappy Bird在一小群游戏爱好者的关注中下载量开始上升。一直到一月底、二月初,它在iOS上上架的150多个国家中登顶了140个国家的免费下载榜榜首,这是何等让人咂舌的成绩。游戏最后能够流行起来可能有一些大家跟风的心理,但可以肯定的是它绝对非常优秀,才能获得如此口碑。但同样的游戏,为何前后有如此大的反差,如果那名用户没有在论坛中推荐,那是否到现在它仍然默默的躺在App Store某个不起眼的地方?又或者如果当时不是在Reddit上推荐,而是另外一个关注度没有那么高的论坛中,是否又会是另外一番景象?可以肯定的说,正是由于它意外的出现在关注度极高的平台中,才没有泯然众人。 这又好似新闻头条,不知多少人和我一样,很多新闻都只是简单的浏览一遍首页的几条。如果新闻不能够登上头条的话,无法获得足够的关注度,很快便会被人们所忽略。现在可以理解明星们不择手段为了登上头条来增加自己的曝光度,同时对那些位于背后,又甘于默默工作的幕后工作者们充满了敬意。 比较幸运的是,我们处于一个比较开放的年代,又有了人类历史上迄今为止最有影响力的发明之一——互联网,让每个人都有权利,有机会向更多的人展现自己,于是各种以此目的开发的产品涌现出来,BBS,博客,贴吧,微博等等,这些都是平台。在体验到它们便利的同时,这也带来了另外一个问题,平台太多,关注点分散,另外人们接受信息量越来越大,导致即使是再热门的东西过不了多久便会渐渐消退,淡出人们视野。很多极好的东西很可惜的被淹没在无尽的信息之中,有些甚至完全没有被关注过。假如…可惜没有假如。我能够想象到它们的创造者内心的不甘与无奈,也为它们感到深深的惋惜。这也是我喜欢博客而不喜欢微博的一个原因,我希望用心写的文章可以有一个属于它们自己的平台,成为这个平台的头条,即使只有我一个观众。 关于展示方式 这篇帖子其实和原贴没有任何区别,只是对原贴的一个再加工,但是仅从我个人而言,视频这种形式带给我的震撼更大。人天生是一种懒惰的动物,有时会排斥一切麻烦的东西。比如说一本小说,和一部以小说改编的电影,以内容而言,电影不可能会比小说更出色,但很多时候都是电影带给人的震撼更强烈,从而它能够更好的带动小说的销量。究其原因,我想大概是小说只带给人们想象上的体验,但是花费的精力却很巨大,需要眼睛不停的盯着书本,配合大脑发挥自己的想象对看到的内容进行加工。而电影就简单的多,它能够同时给人以视觉,听觉上的冲击,将原来需要大脑加工的内容具象化,直接呈现给观影者。并且音乐和画面相对于文字可以更容易的调动人们的情绪,让人们更轻松的享受整个过程,与导演产生共鸣,从而被更多的人接受。 但是电影却比小说更容易受到争议,剧本改编有差距是一方面,另外更重要的一点,也许是电影的这种具象化无法满足每个读者的期望吧,剥夺了有些人想象的乐趣,剥夺了人们去创造自己心中的哈姆雷特的权利。 关于照片 照片是种神奇的表现形式,它只记录一瞬间,不加任何修饰,但却和很多艺术一样,可以给你带来无尽的想象空间。面对一张照片时,可能每个人的感受都不一样,即使是同一人,在不同时刻的感受也会存在不同。但正因如此,才使得它更加鲜活,更具生命力。我很喜欢一些开放式结局的小说,电影,比如金庸的《雪山飞狐》,诺兰的《盗梦空间》,《禁闭岛》和李安的《少年派的奇幻漂流》。他们为故事设定一个开放式的结局,不点破,为读者和观众留点想象的余地。这样安排需要勇气,但正是这样一种巧妙的安排,恰到好处,可以让读者,观众们尽情的发挥自己的想象,每个人都能找到自己喜爱的方式来解读,获得自己希望的结局,从而使它们被赋予了更强的生命力。 文章开篇的图片便是视频中的一部分,如果没有配文字,看到这张照片时,第一反应可能是天神下凡般的罗伊。可是,一旦加了歌词,意思便截然不同。2006年加入联盟,一出道便获得最佳新秀,随后成为球队最关键的杀手。作为曾经最好的得分后卫之一,他享有黄曼巴的美誉,能力几乎与科比相当,被人当作科比的接班人。铁血的他在2009-2010赛季,全队大部分主力受伤的情况下,带领开拓者力克各路豪强,让开拓者的玫瑰花园球馆成为令人闻风丧胆的魔鬼主场。作为湖人球迷永远都记得那段时间,湖人像中了魔咒一般在这个球馆尝到9连败。火箭球迷也一定记得罗伊的0.8秒一剑封喉。这样一个神一般的男人,就在所有人对他的未来充满无限期待之时,谁能想到2011-2012赛季开赛之前,他因为左右膝盖双双摘除半月板,而不得不宣布退役,那时才年仅26岁。一靠近天堂,也就快醒了,故事还未开始便已结束。视频中最让我痛心的便是这里。假如他还在,假如奥登依然健康,他们是否会像当年的OK组合一样,大杀四方,统治联盟?但这一切都是假如罢了。 结尾 最后以伴随着帖子一起发布的小段文字作为结尾: 这些球星们陪伴着我们成长,而现在他们或老去或离开,或载着荣耀或带着遗憾,一起带走的还有我们关于青春的记忆。看完帖子之后想起《平凡的世界》中孙少平念的一句诗,非常符合此刻的心情:“金黄的落叶铺满我的心间,我已再不是青春少年。” 谨以此片缅怀那段难忘的青春岁月。 附录 附上《假如》的歌词: 一份爱能承受多少的误解 (全文完) feihu 2014.07.18 于 Shenzhen

2014/7/18
articleCard.readMore

知无涯之std::sort源码剖析

从事程序设计行业的朋友一定对排序不陌生,它从我们刚刚接触数据结构课程开始便伴随我们左右,是需要掌握的重要技能。任何一本数据结构的教科书一定会介绍各种各样的排序算法,比如最简单的冒泡排序、插入排序、希尔排序、堆排序等。在现已知的所有排序算法之中,快速排序名如其名,以快速著称,它的平均时间复杂度可以达到O(N logN),是最快排序算法之一。 目录 背景 Introspective Sort 堆排序的优点 插入排序的优点 横空出世 std::sort的实现 递归结构 三点中值法 分割算法 递归深度阈值 最小分段阈值 为何__final_insertion_sort如此实现 各种插入排序算法的实现 标准插入排序实现 __insertion_sort实现 __unguarded_insertion_sort实现 各种实现的性能分析 标准插入排序性能分析 __insertion_sort性能分析 __unguarded_insertion_sort性能分析 比较结果 离真相更进一步 论证最小值存在于前16个元素之中 std::sort适合哪些容器 写在最后 背景 在校期间,为了掌握这些排序算法,我们不得不经常手动实现它们,以加深对其的理解。然而这些算法实在是太常用了,我们不太可能在每次需要时都手动来实现,不管是性能还是安全性都得不到保证。因此这些算法被包含进了很多语言的标准库里,在C语言的标准库中,stdlib.h头文件就有qsort算法,它正是最快排序算法——快速排序的标准实现,这给我们提供了很大的方便。 然而,快速排序虽然平均复杂度为O(N logN),却可能由于不当的pivot选择,导致其在最坏情况下复杂度恶化为O(N2)。另外,由于快速排序一般是用递归实现,我们知道递归是一种函数调用,它会有一些额外的开销,比如返回指针、参数压栈、出栈等,在分段很小的情况下,过度的递归会带来过大的额外负荷,从而拉缓排序的速度。 Introspective Sort 为了解决快速排序在最坏情况下复杂度恶化的问题,人们进行了大量的研究,获得了众多研究成果。本文将要介绍的算法便是其中之一。在开始之前我们需要先简短介绍两个其它常用的算法,这对我们理解新算法为何如此设计非常重要,它们是堆排序和插入排序。 堆排序的优点 堆排序经常是作为快速排序最有力的竞争者出现,它们的复杂度都是O(N logN)。这里有一个维基百科上的动态图片,直观的反应出堆排序的过程: 虽然两者拥有一样的复杂度,但就平均表现而言,它却比快速排序慢了2~5倍,知乎上有一个讨论:堆排序缺点何在?另外还可以参考Comparing Quick and Heap Sorts,还有Basic Comparison of Heap-Sort and Quick-Sort Algorithms,这些都给出了为何堆排序相比快速排序而言慢了这许多。 但是,有一点它却比快速排序要好很多:最坏情况它的复杂度仍然会保持O(N logN),这一优点对本文介绍的新算法有着巨大的作用。 插入排序的优点 再来看看插入排序,同样有一张维基百科上的动态图片,可以唤起你对它的记忆: 它在数据大致有序的情况表现非常好,可以达到O(N),可以参考这个讨论Which sort algorithm works best on mostly sorted data? 这一优点也被新算法所采用。 横空出世 到了正式介绍新算法的时刻。由于快速排序有着前面所描述的问题,因此Musser在1996年发表了一遍论文,提出了Introspective Sorting(内省式排序),这里可以找到PDF版本。它是一种混合式的排序算法,集成了前面提到的三种算法各自的优点: 在数据量很大时采用正常的快速排序,此时效率为O(logN)。 一旦分段后的数据量小于某个阈值,就改用插入排序,因为此时这个分段是基本有序的,这时效率可达O(N)。 在递归过程中,如果递归层次过深,分割行为有恶化倾向时,它能够自动侦测出来,使用堆排序来处理,在此情况下,使其效率维持在堆排序的O(N logN),但这又比一开始使用堆排序好。 由此可知,它乃综合各家之长的算法。也正因为如此,C++的标准库就用其作为std::sort的标准实现。 std::sort的实现 SGI版本的STL一直是评价最高的一个STL实现,在技术层次、源代码组织、源代码可读性上,均有卓越表现。所以它被纳为GNU C++标准程序库。这里选择了侯捷的《STL源码剖析》一书中分析的GNU C++ 2.91版本来作分析,此版本稳定且可读性强。 std::sort的代码如下: template <class RandomAccessIterator> inline void sort(RandomAccessIterator first, RandomAccessIterator last) { if (first != last) { __introsort_loop(first, last, value_type(first), __lg(last - first) * 2); __final_insertion_sort(first, last); } } 它是一个模板函数,只接受随机访问迭代器。if语句先判断区间有效性,接着调用__introsort_loop,它就是STL的Introspective Sort实现。在该函数结束之后,最后调用插入排序。我们来揭开该算法的面纱: template <class RandomAccessIterator, class T, class Size> void __introsort_loop(RandomAccessIterator first, RandomAccessIterator last, T*, Size depth_limit) { while (last - first > __stl_threshold) { if (depth_limit == 0) { partial_sort(first, last, last); return; } --depth_limit; RandomAccessIterator cut = __unguarded_partition (first, last, T(__median(*first, *(first + (last - first)/2), *(last - 1)))); __introsort_loop(cut, last, value_type(first), depth_limit); last = cut; } } 这是算法主体部分,代码虽然不长,但充满技巧,有很多细节需要注意,接下来我们将对其一一展开分析。 递归结构 可以看出它是一个递归函数,因为我们说过,Introspective Sort在数据量很大的时候采用的是正常的快速排序,因此除了处理恶化情况以外,它的结构应该和快速排序一致。但仔细看以上代码,先不管循环条件和if语句(它们便是处理恶化情况所用),循环的后半部分是用来递归调用快速排序。但它与我们平常写的快速排序有一些不同,对比来看,以下是我们平常所写的快速排序的伪代码: function quicksort(array, left, right) // If the list has 2 or more items if left < right // See "#Choice of pivot" section below for possible choices choose any pivotIndex such that left ≤ pivotIndex ≤ right // Get lists of bigger and smaller items and final position of pivot pivotNewIndex := partition(array, left, right, pivotIndex) // Recursively sort elements smaller than the pivot (assume pivotNewIndex - 1 does not underflow) quicksort(array, left, pivotNewIndex - 1) // Recursively sort elements at least as big as the pivot (assume pivotNewIndex + 1 does not overflow) quicksort(array, pivotNewIndex + 1, right) __introsort_loop中只有对右边子序列进行递归调用是不是?左边的递归不见了。的确,这里的写法可读性相对来说比较差,但是仔细一分析发现是有它的道理的,它并不是没有管左子序列。注意看,在分割原始区域之后,对右子序列进行了递归,接下来的last = cut将终点位置调整到了分割点,那么此时的[first, last)区间就是左子序列了。又因为这是一个循环结构,那么在下一次的循环中,左子序列便得到了处理。只是并未以递归来调用。 我们来比较一下两者的区别,试想,如果一个序列只需要递归两次便可结束,即它可以分成四个子序列。原始的方式需要两个递归函数调用,接着两者各自调用一次,也就是说进行了7次函数调用,如下图左边所示。但是STL这种写法每次划分子序列之后仅对右子序列进行函数调用,左边子序列进行正常的循环调用,如下图右边所示。 两者区别就在于STL节省了接近一半的函数调用,由于每次的函数调用有一定的开销,因此对于数据量非常庞大时,这一半的函数调用可能能够省下相当可观的时间。真是为了效率无所不用其极,令人惊叹!更关键是这并没有带来太多的可读性的降低,稍稍一经分析便能够读懂。这种稍稍以牺牲可读性来换取效率的做法在STL的实现中比比皆是,本文后面还会有例子。 三点中值法 先从这种惊叹中回过神来,接着看循环的主体部分,其中有一个__median函数,它的作用是取首部、尾部和中部三个元素的中值作为pivot。我们之前学到的快速排序都是选择首部、尾部或者中间位置的元素作为pivot,并不会比较它们的值,在很多情况下这将引起递归的恶化。现在这里采用的中值法可以在绝大部分情形下优于原来的选择。 分割算法 主循环中另外一个重要的函数是__unguarded_partition,这其实就是我们平常所使用的快速排序主体部分,用于根据pivot将区间分割为两个子序列。其源码如下: template <class RandomAccessIterator, class T> RandomAccessIterator __unguarded_partition(RandomAccessIterator first, RandomAccessIterator last, T pivot) { while (true) { while (*first < pivot) ++first; --last; while (pivot < *last) --last; if (!(first < last)) return first; iter_swap(first, last); ++first; } } 它会不断去交换放错位置的元素,直到first和last指针相互交错为止,函数返回的是右边区间的起始位置。注意看:这个函数没有对first和last作边界检查,而是以两个指针交错作为中止条件,节约了比较运算的开支。可以这么做的理由是因为,选择是首尾中间位置三个值的中间值作为pivot,因此一定会在超出此有效区域之前中止指针的移动。《STL源码剖析》给出了两个非常直观的示意图: 分割示例一 分割示例二 相信这两个图可以让你非常容易明白这个分割算法。 递归深度阈值 现在我们来关注循环条件和if语句。__introsort_loop的最后一个参数depth_limit是前面所提到的判断分割行为是否有恶化倾向的阈值,即允许递归的深度,调用者传递的值为2logN。注意看if语句,当递归次数超过阈值时,函数调用partial_sort,它便是堆排序: template <class RandomAccessIterator, class T, class Compare> void __partial_sort(RandomAccessIterator first, RandomAccessIterator middle, RandomAccessIterator last, T*, Compare comp) { make_heap(first, middle, comp); for (RandomAccessIterator i = middle; i < last; ++i) if (comp(*i, *first)) __pop_heap(first, middle, i, T(*i), comp, distance_type(first)); sort_heap(first, middle, comp); } template <class RandomAccessIterator, class Compare> inline void partial_sort(RandomAccessIterator first, RandomAccessIterator middle, RandomAccessIterator last, Compare comp) { __partial_sort(first, middle, last, value_type(first), comp); } 如前所述,此时采用堆排序可以将快速排序的效率从O(N2)提升到O(N logN),杜绝了过度递归所带来的开销。堆排序结束之后直接结束当前递归。 最小分段阈值 除了递归深度阈值以外,Introspective Sort还用到另外一个阈值。注意看__introsort_loop中的while语句,其中有一个变量__stl_threshold,其定义为: const int __stl_threshold = 16; 它就是我们前面所说的最小分段阈值。当数据长度小于该阈值时,再使用递归来排序显然不划算,递归的开销相对来说太大。而此时整个区间内部有多个元素个数少于16的子序列,每个子序列都有相当程度的排序,但又尚未完全排序,过多的递归调用是不可取的。而这种情况刚好插入排序最拿手,它的效率能够达到O(N)。因此这里中止快速排序,sort会接着调用外部的__final_insertion_sort,即插入排序来处理未排序完全的子序列。 到目前为止一切都很好理解。 为何__final_insertion_sort如此实现 现在终于来到std::sort的最后一步——插入排序。将它作为单独的一章是因为它使用了些优化技巧,让人难以理解,我花了些时间才弄懂它,这也正是为何会有本文的根本原因。我们先来看看其定义: template <class RandomAccessIterator> void __final_insertion_sort(RandomAccessIterator first, RandomAccessIterator last) { if (last - first > __stl_threshold) { __insertion_sort(first, first + __stl_threshold); __unguarded_insertion_sort(first + __stl_threshold, last); } else __insertion_sort(first, last); } 它被分成了两个分支,前一个分支是处理大于分段阈值的情况,后一个分支处理小于等于分段阈值。第一个问题:为什么要划分成两种情况不同对待? 再看,第一个分支中又将区间分成了两段,前16个和剩余部分,然后分别调用两个排序。于是第二个问题来了,为什么要这么分段? 最后一个问题,__insertion_sort和__unguarded_insertion_sort有何区别? 这此问题便是我看到这个实现的疑惑,为什么不直接使用插入排序?但是很遗憾的是《STL源码剖析》并未讲得很清楚,网上也有类似的讨论,都说是为了优化,但为何这样便能优化,还是没有答案。如果你也无法回答上述三个问题,那么请跟随我一起来讨论。 各种插入排序算法的实现 我们这里先来看最后一个问题,这两种插入排序有何区别?要解释这个问题,需要先介绍它们各自的实现,从标准插入排序算法开始。 标准插入排序实现 插入排序很简单,本文前面的动态图可以很直观的展示它的原理。这里是摘自维基百科的一段伪代码: for i ← 1 to length(A) j ← i while j > 0 and A[j-1] > A[j] swap A[j] and A[j-1] j ← j - 1 从第二个值开始遍历每个元素,首先判断是否有越界,然后判断是否需要交换。 __insertion_sort实现 那么同样都是插入排序,__insertion_sort和__unguarded_insertion_sort有何不同,为什么叫unguarded?接下来看看STL的实现:(注:这里取得都是采用默认比较函数的版本): template <class RandomAccessIterator, class T> void __unguarded_linear_insert(RandomAccessIterator last, T value) { RandomAccessIterator next = last; --next; while (value < *next) { *last = *next; last = next; --next; } *last = value; } template <class RandomAccessIterator, class T> inline void __linear_insert(RandomAccessIterator first, RandomAccessIterator last, T*) { T value = *last; if (value < *first) { copy_backward(first, last, last + 1); *first = value; } else __unguarded_linear_insert(last, value); } template <class RandomAccessIterator> void __insertion_sort(RandomAccessIterator first, RandomAccessIterator last) { if (first == last) return; for (RandomAccessIterator i = first + 1; i != last; ++i) __linear_insert(first, i, value_type(first)); } 最下面的函数,它是从第二个元素开始对每个元素依次调用了__linear_insert。后者和前面提到的标准插入排序有一点点不同,它会先将该值和第一个元素进行比较,如果比第一个元素还小,那么就直接将前面已经排列好的数据整体向后移动一位,然后将该元素放在起始位置。对于这种情况,和标准插入排序相比,它将last - first - 1次的比较与交换操作变成了一次copy_backward操作,节省了每次移动前的比较操作。 但这还不是最主要的。如果该元素并不小于第一个元素,它会调用另外一个函数__unguarded_linear_insert,这里仅仅挨个判断是否需要调换,找到位置之后就将其插入到适当位置。注意看,这里没有检查是否有越界,为什么可以这样?因为在__linear_insert的if语句中,已经可以确保第一个值在最左边了,如果不在最左边,它便不可能进入这个函数,会执行第一个分支。那么,__unguarded_linear_insert便可以毫无顾忌的省略掉越界的检查。当然,因为少了很多次的比较操作,效率肯定便有了提升。后面我们会就此作一个详细的分析。 注意:使用__unguarded_linear_insert时,一定得确保这个区间的左边有效范围内已经有了最小值,否则没有越界检查将可能带来非常严重的后果。这种unguarded命名的函数在前面__introsort_loop里面也有一个:__unguarded_partition,这也是同样不考虑边界的情况,前面已经介绍过。 __unguarded_insertion_sort实现 最后再来看看__unguarded_insertion_sort在STL中的实现,同样这里只是默认比较函数版本: template <class RandomAccessIterator, class T> void __unguarded_insertion_sort_aux(RandomAccessIterator first, RandomAccessIterator last, T*) { for (RandomAccessIterator i = first; i != last; ++i) __unguarded_linear_insert(i, T(*i)); } template <class RandomAccessIterator> inline void __unguarded_insertion_sort(RandomAccessIterator first, RandomAccessIterator last) { __unguarded_insertion_sort_aux(first, last, value_type(first)); } 可以忽略掉这层aux函数的包装,它只是为了获得迭代器所指向的类型,其实这两个函数可以合并为一个。这里直接对每个元素都调用__unguarded_linear_insert,这个函数我们在上节已经分析过,它不对边界作检查。正因为如此,它一定比前面的__insertion_sort要快。 但是有一点需要再次强调一遍:和前面的__unguarded_linear_insert一样,一定得确保这个区间的左边有效范围内已经有了最小值,否则没有越界检查将可能带来非常严重的后果。 各种实现的性能分析 接下来我们对以上三种实现的性能作一个分析,这里仅以第i个元素的运算次数作为比较,并不考虑编译器优化,所以只是一个粗略的性能分析。此时前i-1个元素已经排好,假设该第i个元素应该插入的位置离i的平均距离为N。 标准插入排序性能分析 对于标准插入排序,它需要的操作次数为: // 标准插入排序伪代码 while j > 0 and A[j-1] > A[j] // 2N次比较运算,N次减法运算 swap A[j] and A[j-1] // N次交换运算(通常理解为3N次赋值运算) j ← j - 1 // N次自减运算 总共为2N次比较,3N次赋值,N次减法,N次自减。 __insertion_sort性能分析 再来看__insertion_sort,因为这里出现了分支,因此需要分开来对待。我们取两种极端情况,先假设每次都是取第一个分支,即value < *first,那么此时N=i: // __linear_insert函数 if (value < *first) { // 1次比较运算 copy_backward(first, last, last + 1); // 1次copy_backward *first = value; // 1次赋值运算 } 因为copy_backward最后调用的是memmove,它在C标准库中实现为: // memmove函数 for (; 0 < n; --n) // N次比较运算,N次自减运算 *sc1++ = *sc2++; // 2N次自增运算,N次赋值运算 这里认为自增自减一样,因此总共需要N+1次比较,N+1次赋值,3N次自减。 如果假设每次__insertion_sort都不取第一个分支,即首位的元素已经是最小值,此时: // __linear_insert函数 if (value < *first) { // 1次比较 // ... } else __unguarded_linear_insert(last, value); // 见下面 // __unguarded_linear_insert函数 while (value < *next) { // N次比较 *last = *next; // N次赋值 last = next; // N次赋值 --next; // N次自减 } 因此总共需要N+1次比较,2N次赋值和N次自减。 再假设两个分支有相同的概率(实际上第二个分支的可能性更大些,在经过__introsort_loop之后的数据更是如此,因为此时最小值已经可以确认位于前16个元素之后,这在后面有证明。因此很快最小值便可以移动到最左端,那么就必然走第二个分支),那么平均所需的操作为:N+0.5次比较,1.5N+0.5次赋值,2N次自减。 __unguarded_insertion_sort性能分析 由于其直接调用了__unguarded_linear_insert,因而和上述第二个分支类似,但没有了分支判断,所需操作为:N次比较,2N次赋值和N次自减。 比较结果 在上述基础上再作一点假设,假设每种运算占用CPU的时间一样,那么此时三种算法的结果分别为: 2N + 3N + N + N = 7N N + 0.5 + 1.5N + 0.5 + 2N = 4.5N + 1 N + 2N + N = 4N 假设总共为M个元素,那么平均执行次数为: 7N * M 4.5N * M + M 4N * M 可以很明显的看到,后两种的执行次数远远低于第一种,接近标准实现的一半。而最后一种因为少了越界检查,乍看之下似乎无足轻重,但在M非常庞大的情况下,影响相当可观,毕竟这是一个非常根本的算法核心。这也是一直没有省略1的原因。 离真相更进一步 让我们回到__final_insertion_sort函数,为了唤醒你的记忆,再贴一次它的源代码: template <class RandomAccessIterator> void __final_insertion_sort(RandomAccessIterator first, RandomAccessIterator last) { if (last - first > __stl_threshold) { __insertion_sort(first, first + __stl_threshold); __unguarded_insertion_sort(first + __stl_threshold, last); } else __insertion_sort(first, last); } 此时前面提的最后一个问题:两种插入算法有何区别?已经有了答案:一个带边界检查而另一个不带,不带边界检查的__unguarded_insertion_sort更快。 那么为什么不直接使用它呢?还记得前面每次介绍它时都有一行加粗的话么?这是因为它有一个前提条件,那便是需要确保最小值已经存在于有效区间的最左边。于是,你可能会想,如果此时可以确定最小值已经位于最左边,那么后面所有的区间内便可以使用最快的__unguarded_insertion_sort算法。没错,STL的设计者也是这么想的。 可是,如何可以确定最小值已经在最左边了呢?或者在一个小的区间内?绝大部分情况下无法确定。但正是由于快速排序的特殊性,可以保证最小值存在于一个小的区域中,接下来我们会证明这一点。 所以他们想到将经过__introsort_loop排序的数据分成两段,假设第一段里面包含了最小值,那么将第一段使用__insertion_sort排序,后一段使用__unguarded_insertion_sort便可以达到效率的最大化。对,STL的设计者们珍爱效率如生命。 到这里,你可以回答第一个问题了:为什么有这样的分支处理?是因为如果数据量足够小,没有必要进行如此复杂的划分,直接一个插入排序便可以搞定。只数据量比较大的情况下,将数据分成两段,前一段使用带边界检查的插入排序,后一段使用不带边界检查的插入排序。 现在最为关键的一个问题来了,如何可以确保前16个元素中一定有最小值? 论证最小值存在于前16个元素之中 我们看一下维基百科上快速排序的动画,非常直观: 从图中可以看出,无论经过几次递归调用,对于所有划分的区域,左边区间所有的数据一定比右边小,记住这一点,它将为后面的推理起到重要的作用。 再来看一眼__introsort_loop: template <class RandomAccessIterator, class T, class Size> void __introsort_loop(RandomAccessIterator first, RandomAccessIterator last, T*, Size depth_limit) { while (last - first > __stl_threshold) { if (depth_limit == 0) { partial_sort(first, last, last); return; } --depth_limit; RandomAccessIterator cut = __unguarded_partition (first, last, T(__median(*first, *(first + (last - first)/2), *(last - 1)))); __introsort_loop(cut, last, value_type(first), depth_limit); last = cut; } } 该函数只有两种情况下可能返回,一是区域小于等于阈值16;二是超过递归深度阈值。我们现在只考虑最左边的子序列,先假设是由于第一种情况终止了这个函数,那么该子区域小于16。再根据前面的结论:左边区间的所有数据一定比右边小,可以推断出最小值一定在该小于16的子区域内。 假设函数是第二种情况下终止,那么对于最左边的区间,由于递归深度过深,因此该区间会调用堆排序,所以这段区间的最小值一定位于最左端。再加上前面的结论:左边区间所有的数据一定比右边小,那么该区间内最左边的数据一定是整个序列的最小值。 因此,不论是哪种情况,都可以保证起始的16个元素中一定有最小值。如此便能够使用__insertion_sort对前16个元素进行排序,接着用__unguarded_insertion_sort毫无顾忌的在不考虑边界的情况下对剩于的区间进行更快速的排序。 至此,所有三个问题都得到了解答。 std::sort适合哪些容器 这么高效的算法,是不是所有的容器都可以使用呢?我们常规数组是否也能使用?我们知道在STL中的容器可以大致分为: 序列式容器:vector, list, deque 关联式容器:set, map, multiset, multimap 配置器容器:queue, stack, priority_queue 无序关联式容器:unordered_set, unordered_map, unordered_multiset, unordered_multimap。这些是在C++ 11中引入的 对于所有的关联式容器如map和set,由于它们底层是用红黑树实现,因此已经具有了自动排序功能,不需要std::sort。至于配置器容器,因为它们对出口和入口做了限制,比如说先进先出,先进后出,因此它们也禁止使用排序功能。 由于std::sort算法内部需要去取中间位置元素的值,为了能够让访问元素更迅速,因此它只接受有随机访问迭代器的容器。对于所有的无序关联式容器而言,它们只有前向迭代器,因而无法调用std::sort。但我认为更为重要的是,从它们名称来看,本身就是无序的,它们底层是用哈希表来实现。它们的作用像是字典,为的是根据key快速访问对应的元素,所以对其排序是没有意义的。 剩下的三种序列式容器中,vector和deque拥有随机访问迭代器,因此它们可以使用std::sort排序。而list只有双向迭代器,所以它无法使用std::sort,但好在它提供了自己的sort成员函数。 另外,我们最常使用的数组其实和vector一样,它的指针本质上就是一种迭代器,而且是随机访问迭代器,因此也可以使用std::sort。 写在最后 以上便是我所知道的std::sort的所有秘密。仅仅数十行代码,就包含了如此多的技巧,为得只有一个目的:尽最大可能提高算法效率。正如孟岩所说: STL是精致的软件框架,是为优化效率而无所不用其极的艺术品,是数据结构与算法大师经年累月的智能结晶,是泛型思想的光辉诗篇,是C++高级技术的精彩亮相! 原来只是因为没看明白__final_insertion_sort函数,弄清之后才打算写一篇简短的文章来记录下,所以原来也打算只重点讨论这个函数。可写着写着就发现这个函数脱离不了std::sort,整个std::sort的实现过程中又有着各种各样的考虑,很多的细节在书中、网络上都没有得到解释,如果想要彻底弄明白它的话,需要花费些精力。就如侯捷在《STL源码剖析》一书的自序中所言,这本书的写作动机,纯属偶然。本文也一样,想到既然对这段代码花费了些时间去理解,并从中获益,那我想,应该会有其它人也能从这篇文章中获益吧,那为何不将关于这个算法的所有理解都整理出来呢?于是才有了本文最终的版本。写的过程中颇有感触,自我在脑海中或者笔记上总结,和汇聚成文相比完全是两回事,有太多的背景要介绍,述说的方式,结构的安排等等无一不需花费心思。现在可以体会出每个写作者的艰辛之处,更对认真完成众多经典作品的作者们充满了敬佩。 虽然现在人们认为STL存在非常多的诟病,比如引起代码膨胀、性能下降或者是编译信息难以阅读等,但我认为对于C++而言,它就像C的标准库相对于C语言,可以让我们的工作事半功倍,大大提高工作效率,它是语言不可或缺的部分。毕竟这是C++的标准委员会及众多C++专家们画了数十载的心血,无论是在稳定性、安全性、通用性还是效率上都经历住了很大的考验,如果自己从头开始设计一个自认为更好的库,真的能达到这么好的效果么?看看本文所讨论的std::sort,我实在很难想象还有比STL对它的实现更有效率的做法。当然,如果项目很特殊,比如禁用异常,或者已经有针对项目更高效稳定的量身定制的库,那么我可以理解禁止使用STL。但总的来说,在大部分情况下,有这么好的标准工具,为什么要熟视无睹呢? (全文完) feihu 2014.05.21 于 Shenzhen

2014/5/21
articleCard.readMore

知无涯之C++ typename的起源与用法

侯捷在Effective C++的中文版译序中提到: C++的难学,还在于它提供了四种不同(但相辅相成)的程序设计思维模式:procedural-based, object-based, object-oriented, generics 对于较少使用最后一种泛型编程的我来说,程序设计基本上停留在前三种思维模式当中。虽说不得窥见高深又现代的泛型技术,但前三种思维模式已几乎满足我所遇到的所有需求,因此一直未曾深入去了解泛型编程。 目录 起因 typename的常见用法 typename的来源 一些关键概念 限定名和非限定名 依赖名和非依赖名 类作用域 引入typename的真实原因 一个例子 问题浮现 千呼万唤始出来 不同编译器对错误情况的处理 使用typename的规则 其它例子 再看常见用法 参考 写在结尾 起因 近日,看到这样一行代码: typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor; 虽说已经有多年C++经验,但上面这短短一行代码却看得我头皮发麻。看起来它应该是定义一个类型别名,但是typedef不应该是像这样使用么,typedef+原类型名+新类型名: typedef char* PCHAR; 可为何此处多了一个typename?另外__type_traits又是什么?看起来有些眼熟,想起之前在Effective C++上曾经看过traits这一技术的介绍,和这里的__type_traits有点像。只是一直未曾遇到需要traits的时候,所以当时并未仔细研究。然而STL中大量的充斥着各种各样的traits,一查才发现原来它是一种非常高级的技术,在更现的高级语言中已经很普遍。因此这次花了些时间去学习它,接下来还有会有另一篇文章来详细介绍C++的traits技术。在这里,我们暂时忘记它,仅将它当成一个普通的类,先来探讨一下这个多出来的typename是怎么回事? typename的常见用法 对于typename这个关键字,如果你熟悉C++的模板,一定会知道它有这样一种最常见的用法(代码摘自C++ Primer): // implement strcmp-like generic compare function // returns 0 if the values are equal, 1 if v1 is larger, -1 if v1 is smaller template <typename T> int compare(const T &v1, const T &v2) { if (v1 < v2) return -1; if (v2 < v1) return 1; return 0; } 也许你会想到上面这段代码中的typename换成class也一样可以,不错!那么这里便有了疑问,这两种方式有区别么?查看C++ Primer之后,发现两者完全一样。那么为什么C++要同时支持这两种方式呢?既然class很早就已经有了,为什么还要引入typename这一关键字呢?问的好,这里面有一段鲜为人知的历史(也许只是我不知道:-))。带着这些疑问,我们开始探寻之旅。 typename的来源 对于一些更早接触C++的朋友,你可能知道,在C++标准还未统一时,很多旧的编译器只支持class,因为那时C++并没有typename关键字。记得我在学习C++时就曾在某本C++书籍上看过类似的注意事项,告诉我们如果使用typename时编译器报错的话,那么换成class即可。 一切归结于历史。 Stroustrup在最初起草模板规范时,他曾考虑到为模板的类型参数引入一个新的关键字,但是这样做很可能会破坏已经写好的很多程序(因为class已经使用了很长一段时间)。但是更重要的原因是,在当时看来,class已完全足够胜任模板的这一需求,因此,为了避免引起不必要的麻烦,他选择了妥协,重用已有的class关键字。所以只到ISO C++标准出来之前,想要指定模板的类型参数只有一种方法,那便是使用class。这也解释了为什么很多旧的编译器只支持class。 但是对很多人来说,总是不习惯class,因为从其本来存在的目的来说,是为了区别于语言的内置类型,用于声明一个用户自定义类型。那么对于下面这个模板函数的定义(相对于上例,仅将typename换成了class): template <class T> int compare(const T &v1, const T &v2) { if (v1 < v2) return -1; if (v2 < v1) return 1; return 0; } 从表面上看起来就好像这个模板的参数应该只支持用户自定义类型,所以使用语言内置类型或者指针来调用该模板函数时总会觉得有一丝奇怪(虽然并没有错误): int v1 = 1, v2 = 2; int ret = compare(v1, v2); int *pv1 = NULL, *pv2 = NULL; ret = compare(pv1, pv2); 令人感到奇怪的原因是,class在类和模板中表现的意义看起来存在一些不一致,前者针对用户自定义类型,而后者包含了语言内置类型和指针。也正因为如此,人们似乎觉得当时没有引入一个新的关键字可能是一个错误。 这是促使标准委员会引入新关键字的一个因素,但其实还有另外一个更加重要的原因,和文章最开始那行代码相关。 一些关键概念 在我们揭开真实原因的面纱之前,先保持一点神秘感,因为为了更好的理解C++标准,有几个重要的概念需要先行介绍一下。 限定名和非限定名 限定名(qualified name),故名思义,是限定了命名空间的名称。看下面这段代码,cout和endl就是限定名: #include <iostream> int main() { std::cout << "Hello world!" << std::endl; } cout和endl前面都有std::,它限定了std这个命名空间,因此称其为限定名。 如果在上面这段代码中,前面用using std::cout;或者using namespace std;,然后使用时只用cout和endl,它们的前面不再有空间限定std::,所以此时的cout和endl就叫做非限定名(unqualified name)。 依赖名和非依赖名 依赖名(dependent name)是指依赖于模板参数的名称,而非依赖名(non-dependent name)则相反,指不依赖于模板参数的名称。看下面这段代码: template <class T> class MyClass { int i; vector<int> vi; vector<int>::iterator vitr; T t; vector<T> vt; vector<T>::iterator viter; }; 因为是内置类型,所以类中前三个定义的类型在声明这个模板类时就已知。然而对于接下来的三行定义,只有在模板实例化时才能知道它们的类型,因为它们都依赖于模板参数T。因此,T, vector<T>和vector<T>::iterator称为依赖名。前三个定义叫做非依赖名。 更为复杂一点,如果用了typedef T U; U u;,虽然T没再出现,但是U仍然是依赖名。由此可见,不管是直接还是间接,只要依赖于模板参数,该名称就是依赖名。 类作用域 在类外部访问类中的名称时,可以使用类作用域操作符,形如MyClass::name的调用通常存在三种:静态数据成员、静态成员函数和嵌套类型: struct MyClass { static int A; static int B(); typedef int C; } MyClass::A, MyClass::B, MyClass::C分别对应着上面三种。 引入typename的真实原因 结束以上三个概念的讨论,让我们接着揭开typename的神秘面纱。 一个例子 在Stroustrup起草了最初的模板规范之后,人们更加无忧无虑的使用了class很长一段时间。可是,随着标准化C++工作的到来,人们发现了模板这样一种定义: template <class T> void foo() { T::iterator * iter; // ... } 这段代码的目的是什么?多数人第一反应可能是:作者想定义一个指针iter,它指向的类型是包含在类作用域T中的iterator。可能存在这样一个包含iterator类型的结构: struct ContainsAType { struct iterator { /*...*/ }; // ... }; 然后像这样实例化foo: foo<ContainsAType>(); 这样一来,iter那行代码就很明显了,它是一个ContainsAType::iterator类型的指针。到目前为止,咱们猜测的一点不错,一切都看起来很美好。 问题浮现 在类作用域一节中,我们介绍了三种名称,由于MyClass已经是一个完整的定义,因此编译期它的类型就可以确定下来,也就是说MyClass::A这些名称对于编译器来说也是已知的。 可是,如果是像T::iterator这样呢?T是模板中的类型参数,它只有等到模板实例化时才会知道是哪种类型,更不用说内部的iterator。通过前面类作用域一节的介绍,我们可以知道,T::iterator实际上可以是以下三种中的任何一种类型: 静态数据成员 静态成员函数 嵌套类型 前面例子中的ContainsAType::iterator是嵌套类型,完全没有问题。可如果是静态数据成员呢?如果实例化foo模板函数的类型是像这样的: struct ContainsAnotherType { static int iterator; // ... }; 然后如此实例化foo的类型参数: foo<ContainsAnotherType>(); 那么,T::iterator * iter;被编译器实例化为ContainsAnotherType::iterator * iter;,这是什么?前面是一个静态成员变量而不是类型,那么这便成了一个乘法表达式,只不过iter在这里没有定义,编译器会报错: error C2065: ‘iter’ : undeclared identifier 但如果iter是一个全局变量,那么这行代码将完全正确,它是表示计算两数相乘的表达式,返回值被抛弃。 同一行代码能以两种完全不同的方式解释,而且在模板实例化之前,完全没有办法来区分它们,这绝对是滋生各种bug的温床。这时C++标准委员会再也忍不住了,与其到实例化时才能知道到底选择哪种方式来解释以上代码,委员会决定引入一个新的关键字,这就是typename。 千呼万唤始出来 我们来看看C++标准: A name used in a template declaration or definition and that is dependent on a template-parameter is assumed not to name a type unless the applicable name lookup finds a type name or the name is qualified by the keyword typename. 对于用于模板定义的依赖于模板参数的名称,只有在实例化的参数中存在这个类型名,或者这个名称前使用了typename关键字来修饰,编译器才会将该名称当成是类型。除了以上这两种情况,绝不会被当成是类型。 因此,如果你想直接告诉编译器T::iterator是类型而不是变量,只需用typename修饰: template <class T> void foo() { typename T::iterator * iter; // ... } 这样编译器就可以确定T::iterator是一个类型,而不再需要等到实例化时期才能确定,因此消除了前面提到的歧义。 不同编译器对错误情况的处理 但是如果仍然用ContainsAnotherType来实例化foo,前者只有一个叫iterator的静态成员变量,而后者需要的是一个类型,结果会怎样?我在Visual C++ 2010和g++ 4.3.4上分别做了实验,结果如下: Visual C++ 2010仍然报告了和前面一样的错误: error C2065: ‘iter’ : undeclared identifier 虽然我们已经用关键字typename告诉了编译器iterator应该是一个类型,但是用一个定义了iterator变量的结构来实例化模板时,编译器却选择忽略了此关键字。出现错误只是由于iter没有定义。 再来看看g++如何处理这种情况,它的错误信息如下: In function ‘void foo() [with T = ContainsAnotherType]’: instantiated from here error: no type named ‘iterator’ in ‘struct ContainsAnotherType’ g++在ContainsAnotherType中没有找到iterator类型,所以直接报错。它并没有尝试以另外一种方式来解释,由此可见,在这点上,g++更加严格,更遵循C++标准。 使用typename的规则 最后这个规则看起来有些复杂,可以参考MSDN: typename在下面情况下禁止使用: 模板定义之外,即typename只能用于模板的定义中 非限定类型,比如前面介绍过的int,vector<int>之类 基类列表中,比如template <class T> class C1 : T::InnerType不能在T::InnerType前面加typename 构造函数的初始化列表中 如果类型是依赖于模板参数的限定名,那么在它之前必须加typename(除非是基类列表,或者在类的初始化成员列表中) 其它情况下typename是可选的,也就是说对于一个不是依赖名的限定名,该名称是可选的,例如vector<int> vi; 其它例子 对于不会引起歧义的情况,仍然需要在前面加typename,比如: template <class T> void foo() { typename T::iterator iter; // ... } 不像前面的T::iterator * iter可能会被当成乘法表达式,这里不会引起歧义,但仍需加typename修饰。 再看下面这种: template <class T> void foo() { typedef typename T::iterator iterator_type; // ... } 是否和文章刚开始的那行令人头皮发麻的代码有些许相似?没错!现在终于可以解开typename之迷了,看到这里,我相信你也一定可以解释那行代码了,我们再看一眼: typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor; 它是将__type_traits<T>这个模板类中的has_trivial_destructor嵌套类型定义一个叫做trivial_destructor的别名,清晰明了。 再看常见用法 既然typename关键字已经存在,而且它也可以用于最常见的指定模板参数,那么为什么不废除class这一用法呢?答案其实也很明显,因为在最终的标准出来之前,所有已存在的书、文章、教学、代码中都是使用的是class,可以想像,如果标准不再支持class,会出现什么情况。 对于指定模板参数这一用法,虽然class和typename都支持,但就个人而言我还是倾向使用typename多一些,因为我始终过不了class表示用户定义类型这道坎。另外,从语义上来说,typename比class表达的更为清楚。C++ Primer也建议使用typename: 使用关键字typename代替关键字class指定模板类型形参也许更为直观,毕竟,可以使用内置类型(非类类型)作为实际的类型形参,而且,typename更清楚地指明后面的名字是一个类型名。但是,关键字typename是作为标准C++的组成部分加入到C++中的,因此旧的程序更有可能只用关键字class。 参考 C++ Primer Effective C++ A Description of the C++ typename keyword 维基百科typename 另外关于typename的历史,Stan Lippman写过一篇文章,Stan Lippman何许人,也许你不知道他的名字,但看完这些你一定会发出,“哦,原来是他!”:他是 C++ Primer, Inside the C++ Object Model, Essential C++, C# Primer 等著作的作者,另外他也曾是Visual C++的架构师。 在StackOverflow上有一个非常深入的回答,感谢@Emer 在本文评论中提供此链接。 写在结尾 一个简单的关键字就已经充满曲折,这可以从一个角度反映出一门语言的发展历程,究竟要经历多少决断、波折与妥协,最终才发展成为现在的模样。在一个特定的时期,由于历史、技术、思想等各方面的因素,设计总会向现实做出一定的让步,出现一些“不完美”的设计,为了保持向后兼容,有些“不完美”的历史因素被保留了下来。现在我可以理解经常为人所诟病的Windows操作系统,Intel芯片,IE浏览器,Visual C++等,为了保持向后兼容,不得不在新的设计中仍然保留这些“不完美”,虽然带来的是更多的优秀特性,但有些人却总因为这些历史因素而唾弃它们,也为自己曾有一样的举动而羞愧不已。但也正是这些“不完美”的出现,才让人们在后续的设计中更加注意,站在前人的肩膀上,做出更好,更完善的设计,于是科技才不断向前推进。 然而也有一些敢于大胆尝试的例子,比如C++ 11,它的变化之大甚至连Stroustrup都说它像一门新语言。对于有着30余年历史的“老”语言,不仅没有被各种新贵击溃,反而在不断向晚辈们借鉴,吸纳一些好的特性,老而弥坚,这十分不易。还有Python 3,为了清理2.x版本中某些语法方面的问题,打破了与2.x版本的向后兼容性,这种牺牲向后兼容换取进步的做法固然延缓了新版本的接受时间,但我相信这是向前进步的阵痛。Guido van Rossum的这种破旧立新的魄力实在让人钦佩,至于这种做法能否最终为人们所接受,一切交给历史来检验。 (全文完) feihu 2014.05.08 于 Shenzhen

2014/5/8
articleCard.readMore

深入理解log机制

最近在部门内部做了一个关于log机制的知识分享,深入的探讨了log机制中各种概念的来源、常用log库的用法、内部处理流程,以及如何在一个涉及多台主机的复杂系统中部署log等问题。本文是对这次分享的总结,将对这些问题一一展开介绍。 目录 开场 勿在浮沙筑高台 最简单的log 增加有用信息 简化调用:封装 设定等级:TraceLevel 多一些控制:Marker 改变目的地:Appender 模块独立控制:Category 配置文件 log库常见用法 配置 TraceLevel Marker Appender Formatter Category 处理流程 log在系统中的部署 尾声 开场 log如今已经成为了我们日常开发时所必不可少的工具,它同debug一起构成了开发者手中分析问题最有力的两个武器。两者各有优劣,相辅相成,配合起来使用将变得无往不利。通常相比于debug来说,log在很大程度上可以更方便、更迅速的让开发者分析程序的问题,尤其是对于非常庞大的系统、或者已经发布的程序,又或者一些非必现的问题,当我们无法方便的debug问题程序时,log文件可以提供非常多有用的信息,如果开发者log写得比较合适,大多数情况下根据log就可以分析出问题所在。因此,log分析法深受开发者的喜爱。 记得初学编程,第一次听到这样一个观点时那种难以接受心情,怎么可能还有比debug更加容易分析程序问题的方法?好一个无知无畏!当然这一切都是源于当时写的程序规模都比较小,非常适合debug的缘故吧。而实际上当时在不知不觉中已经或多或少使用了简单的log,那一条条控制台的cout与printf就是最好的证明。后来随着程序规模越来越大,才明白debug的局限性,逐渐的喜欢上了log。 勿在浮沙筑高台 现如今对于每一种开发语言都有非常多的库来帮我们处理log,比如:log4j(log for Java),log4cpp(log for C++),log4net(log for .NET)等等。最早处理log的库是Log4j,它是Apache为Java发布的一个开源log库,后来基于这个库衍生了很多具有相似API的库。我们这里介绍的库是基于log4cpp发展而来。 后面就用log4me作为我们使用的库的名称 让我们先从无到有,从一个个简单的使用场景一步一步分析log库中各种概念如何发展而来。当然,我没有去真正追究它的历史,只是从个人需求角度分析得来。 最简单的log 代码中经常会需要打印一些提示信息用于显示程序工作流程,或者反馈错误信息,这就是所谓的log,就像船员的航海日志一样,我想log也是由此得名吧。为了输出这些信息,在C/C++中最简单的方法是用printf或者std::cout: // I want to print a log: printf("I'm a message\n"); 增加有用信息 我们本可在每处需要打印log信息时都采用这种方式,但不妨先停下来试想一下,如果在一个log文件中你看到满屏幕的这种信息,但是却无法知道是谁,在什么时候,什么位置输出这条信息,那这种log的价值便大大折扣。于是,你会需要在每条log中增加一些额外有用的信息: // I want to add more information: printf("%s %s %d: I'm a message\n", time, __FILE__, __LINE__); 这样,每条log就有了时间,文件和行号这些额外有用的信息,非常有利于分析问题。 简化调用:封装 但是,这样会不会太麻烦?每次在写代码时,打印一条简单的log你需要加这么多无关的内容,万一忘了怎么办,这简直无法接受。你想要把所有的注意力都放在log本身上,不想关注其它的细技末节,怎么办?注意看,上面的函数调用中,后三个参数都是固定的,于是你可以对它进行这样简单的封装: // Too complicated: #define printf0(message) \ printf("%s %s %d %s", time, __FILE__, __LINE__, message); printf0("I'm a message\n"); 注:这里用宏而不采用函数,正如评价中@weitang指出的,如果是函数的话,__LINE__的值会一直是函数中的行号,是一个固定值,而不是调用处的行号。另外,这个版本的宏只支持一个参数,后面调用它的其它函数中传了可能不止一个参数,是为了演示方便。各位有兴趣的话可以自行写出合适的printf0版本。 还是一样简单的调用,不需要你再去输入一些无关的内容,因为这个封装的函数已经替你做好了。 设定等级:TraceLevel log信息并不是千篇一律只起一种作用,有的是纪录程序的流程,有的是错误信息,还有一些是警告信息。为了让log更有可读性,你可能想要把不同的信息区分开来,比如这样: // I want to distinguish different kinds of message: printf0("Normal: I'm a normal message\n"); printf0("Warning: I'm a warning message\n"); printf0("Error: I'm an error message\n"); 那么,你就可以通过在log文件中搜索Normal、Warning或者Error这些关键字就能够找到特定的log。这对于排错帮助非常大,比如你只需要搜索Error关键字就能够得出程序的出错信息。 但是,这些Normal、Warning以及Error关键字需要你每次都加在要输出的字符串中,同前面一样,你还是只想关注log本身,不愿意log和其它的信息混在一起。于是可以这样做: // It's too complicated, I want something like this: enum TraceLevel { Normal, Warning, Error }; void printf1(TraceLevel level, const char *message) { char *levelString[] = { "Normal: ", "Warning: ", "Error: " } printf0("%s %s", message, levelString[level]); } printf1(Normal, "I'm a normal message\n"); printf1(Warning, "I'm a warning message\n"); printf1(Error, "I'm an error message\n"); 现在你只需要指定一种log类型,就可以全心全意的处理log信息本身了。我们把上面的Normal, Warning和Error叫做TraceLevel,故名思义,它表示log的等级。 可以进一步简化: // To be more convenient: void printf_out(const char *message) { printf1(Normal, message); } void printf_warn(const char *message) { printf1(Warning, message); } void printf_error(const char *message) { printf1(Error, message); } printf_out("I'm a normal message\n"); printf_warn("I'm a warning message\n"); printf_error("I'm an error message\n"); 如此一来,对于特定等级的log只需调用各自的log输出函数即可,除此之外,注意力全部放在log信息本身上。 在代码中,通常最多的log是Normal类型,即显示程序流程。有时你可能只想log文件中存储Warning和Error类型的信息,Normal对你来相当于干扰信息,而且log文件也会因此变得很大。有时你又会想让log中包含所有类型。如何协调?如果可以动态的选择哪些等级的信息输出,那岂不是log文件就变得像是根据我的需求定制一般,可以随意控制log包含哪些级别的信息么? 根据这一思路,代码可以这样改变: // I want to add a control which level should be printed: TraceLevel getLevel1(); void printf2(TraceLevel level, const char *message) { if (level >= getLevel1()) printf1(level, message); } printf2(Normal, "I'm a normal message\n"); printf2(Warning, "I'm a warning message\n"); printf2(Error, "I'm an error message\n"); 这里暂时没有采用前面简化的方法。 getLevel1()从配置文件中读取当前允许的Level,代码中只有高于当前Level的log才会被输出,现在log文件便可以随着你的需要而定制了。 多一些控制:Marker 再来考虑这样一种情况,如果你的文件非常大,中间要输出的Normal log非常多,分为不同层次,比如:粗略的流程,详细一些的,十分详细的。和很多命令的-verbose参数一样。由于都是Normal类型的log,所以不能够用前面的TraceLevel,这时需要引入另外一层控制: // My class is too big, I want a filter to determine which // logs should be generated const int SUB = 0; const int TRACE_1 = 1 << 0; const int TRACE_2 = 1 << 1; const int TRACE_3 = 1 << 2; int getMarker1(); void printf3(int marker, TraceLevel level, const char *message) { if (marker == 0 || marker & getMarker1() != 0) printf2(level, message); } printf3(SUB, Normal, "I'm a normal message\n"); printf3(TRACE_1, Normal, "I'm a normal message\n"); printf3(TRACE_2, Normal, "I'm a normal message\n"); 这里提供了四级的控制,和前面的TraceLevel一样,它也可以通过配置文件配置。假设现在配置的是TRACE_1,那么代码中想要输出的三条信息中,只有前两条能够输出。这层控制我们称之为Marker。 注意到这里定义的四级控制是可以通过位来操作的,能够任意组合。如果想要TRACE_1和TRACE_2都能够输出,那么只需要设置: int marker = TRACE_1 | TRACE_2; printf3(marker, Normal, "I'm a normal message\n"); 如果marker设置为SUB,则表明全部输出。通过增加这层控制后,log的订制变得更加灵活。 改变目的地:Appender 到目前为止,所有的log都写到控制台。如果你想log写到文件中怎么办?如果不是控制台应用程序,比如,Win32或者MFC程序,log又该写到哪里去?也许你想到可以使用fwrite代替前面的printf,但是如果你想同时能够将log写到控制台,又写到文件中或者其它地方怎么办? 放弃这种硬编码的方法吧,你可以想到一种更加灵活,可以像前面TraceLevel和Marker一样容易配置的方法,能够更加优雅的控制log输出的目的地,但不需要硬编码在代码中,而是可以配置的。一起来看下面这段代码: // I want my logs to go to console, files, eventlog class Appender { void printf(TraceLevel level, const char *message) = 0; }; class ConsoleAppender: public Appender {/* overwrite printf */}; class FileAppender: public Appender {/* overwrite printf */}; class EventLogAppender: public Appender {/* overwrite printf */}; std::vector<Appender *> &getAppenders(); void printf4(int marker, TraceLevel level, const char *message) { if (marker == 0 || marker & getMarker1() != 0) { if (level >= getLevel1()) { std::vector<Appender *>::iterator it = getAppenders.begin(); for (; it != getAppenders.end(); it++) (*it)->printf(level, message); } } printf4(SUB, Normal, "I'm a normal message\n"); printf4(TRACE_1, Normal, "I'm a normal message\n"); printf4(TRACE_2, Normal, "I'm a normal message\n"); 这里定义了一个叫做Appender的基类,可以理解为处理log目的地的类,它有一个方法printf,对应着如何处理传给它的log。 接下来定义了三个子类,分别代表输出目的地为控制台、文件和Windows的EventLog。它们都覆写了基类的printf方法,按照各自的目的地处理log的流向,比如ConsoleAppender调用前面的printf2函数,而FileAppender可能调用类似的fwrite。这样一来,只要我们为一个程序配置用哪些Appender,log就可以根据这些配置交给对应的Appender子类处理,从而无需在代码中硬编码。 这处理每一种目的地的类我们称之为Appender。 模块独立控制:Category 现在我们的log机制已经足够的完善。但是,随着程序规模越来越大,一个程序所包含的模块也越来越多,有时你并不想要一个全局的配置,而是需要每一个模块可以独立的进行配置,有了前面的介绍,这个需求就变得很简单了: // There are too many components, I want different components // could be configured separately TraceLevel getLevel2(const char *cat); int getMarker2(const char *cat); std::vector<Appender *> &getAppenders2(const char *cat); void printf5(const char *cat, int marder, TraceLevel level, const char *message) { if (marker == 0 || marker & getMarker2(cat) != 0) { if (level >= getLevel2(cat)) { std::vector<Appender *>::iterator it = getAppenders(cat).begin(); for (; it != getAppenders.end(cat); it++) (*it)->printf(level, message); } } printf5("Library1", SUB, Normal, "I'm a normal message\n"); printf5("Library1", TRACE_1, Normal, "I'm a normal message\n"); printf5("Library1", TRACE_2, Normal, "I'm a normal message\n"); 对比前一节的代码,可以发现这里除了增加一个参数const char *cat以外,其它完全一样。但正是这个参数的出现,才让每一个模块可以独立的配置。这种模块间独立进行配置的方法我们称为Category。 配置文件 前面多次提到配置,为了达到可以灵活配置的目的,通常会将这些配置保存成一个文件,比如logConfig.ini: Category: Library1 -> for Library1 category TraceLevel : Warning -> only Warning and Error messages are allowed Markers : TRACE_1 -> only TRACE_1 is allowed Appenders : ConsoleAppender -> write to console FileAppender: -> write to file filePath: C:\temp\log\trace_lib1.log Category: Library2 -> for Library2 category ... 那么在什么时机读取这个配置文件?一般有这样几种方式: 程序启动时载入logConfig.ini,如果配置不常改变时可以采用这种方式,最简单。 创建一个新线程,间隔一段时间检查logConfig.ini是否已经改变,如果改变则重新读取。这种方法比较复杂,可能会影响效率,而且间隔的时间也不好设置。 处理每一个log之前先检测logConfig.ini,如果有改变则重新读取。 最后一种方法结合了前两种方法的优点,还是在处理每个log之前检测,但不同的是再加上一个时间间隔,如果超过时间间隔才会真的去检测,而如果在间隔内,则直接忽略。这种方法更加高效且消耗资源最少。 对于后面三种方式,每次配置文件有了更新之后,log输出几乎可以实时的作出应变。 至此,一个简单灵活的log原型建立了,虽然它还是非常简陋,但已经有了现代log库的雏形,包含了其中几个重要的概念。下面我将以我们所使用的log4me库进行分析。 log库常见用法 前面介绍的log雏形完全是小儿科式的代码,只是起一个演示作用,实际上我们无需重新发明轮子。如本文开始所介绍,已经有非常多专业的库来处理log,这些库以最简单的接口提供了最大化的log信息。我们这里采用的log4me库就有这样几个优点: 跨平台,在Windows和Linux上有着完全一样的接口与行为 更细的粒度来控制log 线程安全 高性能 我们定义了下面几个宏,专门用于Library1下的log输出,这里会取配置中Library1这个Category的配置,分别输出不同TraceLevel的log。 #define LIB1_OUT(MESSAGE) LOG_OUT(Library1, DLL, Notice) << MESSAGE #define LIB1_WARN(MESSAGE) LOG_OUT(Library1, DLL, Warn) << MESSAGE #define LIB1_ERR(MESSAGE) LOG_OUT(Library1, DLL, Error) << MESSAGE 使用时像这样: LIB1_OUT("I'm a message."); LIB1_WARN("I'm a message, ID = " << 1234); LIB1_ERR("I'm a message."); 这里所有的配置都通过配置文件完成,还有一种动态的在代码中创建log的方法,log4cpp的官方网站中有例子,我们这里就不介绍了。 配置 在我们前面的演示代码中,提供了一种非常简单的配置文件,常见的存储配置文件的格式有xml,Windows的ini。log4me中使用的是前者,并且提供了专门的工具来简化其操作,如下图所示: 根据上图我们进一步来看一些概念: TraceLevel TraceLevel用来控制输出的log等级,下面这些比较常用: INOUT : 进入和离开函数 DEBUG : 调试信息,通常用于诊断问题 INFO : 确认一切按计划照常工作 WARNING : 程序仍然可以运行,但有意外发生,或者将来有问题可能要出现(比如硬盘容量低) ERROR : 错误信息 CRITICAL : 严重错误,表示程序可能无法继续运行 ALWAYS : 始终输出log 这些等级按从上到下依次增加的顺序排列,配置TraceLevel后,那么只有上表中位于该level之下的才能够输出。 Marker Marker用来进一步控制log的分类,不像前面的演示代码只定义了四种,通常库会完全使用一个32位的整型来表示这些分类,每一位代表一类,这样就有了32种分类,对于大多数应用场景来说这已经完全足够。 Appender 前面介绍过Appender这个概念,它用来处理log的输出目的地,但真正的库可远不止前面介绍的三种Appender,log4me提供了这些: 注意:最后一个Appender是TraceSrv,它写到memfile中。什么是memfile?这是Linux上的一种内存管理的方法,它将文件映射到内存,通过直接读写内存来操作文件,从而使文件操作变得极其高效便捷,可以参考这里:Linux内存管理之mmap详解。 在Appender中还有一些常用的属性可以配置: CreateNewFile: 表明log库启动时是否创建新文件。 FileCount & FileSize: 用于文件回卷,比如一个log文件lib.log过大时,可以将它重命名为lib.1.log,然后再重新创建lib.log。可以创建多个文件,而这两个参数就用于控制文件数目和单个文件大小。 CategoryFilter: 表明该Appender只处理这个filter列举的Category。 ProcessFilter: 与上面类似,只处理filter列举的进程。 Formatter 这个概念在前面没有介绍过,但它也非常容易理解:每个Appender都可以包含一个formatter,它用来格式化log信息。因为一条log信息可能包含时间,文件名,行号,TraceLevel,进程ID,正文等信息,有时为了简化log输出,对所有的这些分类作一个取舍,从而达到格式化的目的。这很像C语言中的printf。 如果formatter设置的是: %TIME%|%PID%|%LEVEL%|%MARKER%|%CAT%|%FILE%|%LINE%|%FUNC%|%USERTEXT%| 那么log的输出会像这样: 2014/04/07-16:03:35.251560|5560|Notice|SUB|COMP1|main.cpp|78|test|I'm a message| 每一项都和前面formatter中设置的一一对应。 Category 现代的log库一般都将Category组织成树型结构,每一个节点都和前后组成父子关系,根据设置,子节点的Category完全可以继承父节点的配置。所有的Category的根节点是root。这里是一个典型的结构: 一个Category可以包含下面这几个内容: 注意:一个Category可以有多个Appender。 Name, TraceLevel, Marker和Appender这里就不再赘述。上图中有一个Flag,这是什么?它的存在和前面的树型结构息息相关。前面讲到,因为Category被组织成了树型关系,子节点可以继承父节点的配置,那么何时可以继承,如何继承?这就是Flag的作用了,它包含了两个选项: Process Parent: 如果勾选这一项,就表示一个子节点的log可以传给它的父节点处理。这也是为什么很多情况下只需要配置Root节点,其它的子节点设置这个Flag,就可以默认使用Root的全部配置。 Use All Parent Appenders: 如果只有上面的Flag,那么每次信息传到父节点时,父节点都必须根据自身的TraceLevel及Marker进行匹配,只有匹配时才会处理。而如果此Flag打开,那么在传输过程中,只要传输路径上有一个节点匹配,再向上传的所有节点都不再匹配而直接处理。 处理流程 至此你已经完全了解了log的基本概念以及用法,接下来我们更进一步,来看看log内部是如何工作的。 有了前面的演示代码之后再来看log内部处理流程将变得十分简单,大致可以分为两步,第一步过滤: 它在log的调用线程中发生,有些线程可能会对实时性有一定的要求,那么log就不能够在这种线程中去直接执行,而是将创建的log对象加入到队列中,由专门的log工作线程处理,这样就完全不会阻塞住主线程,保证主线程畅通无阻的运行。 流程的第二步是处理消息: 筛选过Category之后会将消息发给每一个合适的Appender,由Appender进一步的筛选及格式化输出。注意在这一步的刚开始有一个Check Config步骤,这和我们前面讲的加载配置文件的时机有关,很明显,这里用的是最后一种读取配置的方案:即每次处理log时,检测配置是否更新。 log在系统中的部署 也许你会想,一个简单的库有什么好部署的,直接拿来用不就得了。可有时因为性能,或者系统过于庞大,配置起来会相当复杂,如果log组织的不好的话,你就会见到log文件满天飞,散落各处的情况。有时你可能会需要一个总的log文件包含所有的信息,一些特定目的的log还要存于不同的文件中。如何保证不同进程,甚至不同的机器上的不同进程能够无冲突的写到同一个log文件中呢?假设一个系统包含一台Windows,一台Linux,如何收集散落各个机器的log?如何方便的在Windows上查看本应出现在Linux上的log?如果你有疑问,请看下面的解决方案: 这个系统足够庞大,包含了两台机器,左边是Windows,右边是Linux。每台机器除各自保存log之外,还将所有的log都最终交给Windows上的TraceSrv来处理,最终会有一份完整的包含所有机器的log存在于TraceSrv.log中,还有各个不同模块的log文件。同时,还能够通过远程调用TraceOnlReader来实时从TraceSrv中读取log信息。如上图所示,两侧绿色的Log图例中,红色的信息沿着箭头先全部汇聚到TraceSrv,然后再分发到不同的文件中。 这样,开发者就可以通过一次配置,便可以非常方便的组织好所有的log文件,调用端完全剔除了这些复杂的细节,只需要关注log本身。 另外注意到,在Windows和Linux端各有一个memfile,它们各自存有机器上的所有log信息,由于是运用了前面所说的mmap机制,程序直接以操作内存的方式来操作文件,非常高效。 尾声 好了,至此所有在知识分享中的内容便介绍完毕,希望对感兴趣的你有所帮助。 我很喜欢部门的知识分享,分享是件好事,在分享的过程中,不仅仅可以让他人获取有用的信息,而且你在分享前需要不断的归纳总结,印证你的结论。在这个过程中,很多你当时思考不充分的问题也可能会得到解决,对你自身的知识、表达能力都有非常大的提高,利人利己。 不知你是否有过这样的经历,你遇到一个问题,百思不得其解,于是想向他人求助,可就在你向他人解释这个问题的过程中,说着说着,你发现你找到问题的所在,于是问题解决了,甚至别人还没明白怎么回事。我就经常遇到这种情况。根据这点,有人总结出了一种新型的解决问题的方法,叫做橡皮鸭调试法,维基百科对它有一个介绍,Jeff Atwood也专门写过一篇文章:Rubber Duck Problem Solving。(至于为什么叫橡皮鸭而不叫其它的,我想大概和美国人的成长经历有关吧,每个孩子洗澡时都喜欢在浴缸中放一只橡皮鸭,并与它交谈,就像我们儿时的各种玩具一样。)这样做其实是有根据的,它和分享如出一辄,当你和橡皮鸭”交谈”时,你需要彻底的把你的问题仔仔细细的描述一遍,不会放过每一个细节,为防质疑,你可能会做更多的调查。你在描述的同时,也一定在思考,这时之前没有考虑到的方面可能就会暴露出来了。如果使用得当,也许,橡皮鸭调试法可以成为log和debug以外,你分析问题又一强有力的武器:-) 最后,强烈建议你去看看Jeff的这篇文章。 (全文完) feihu 2014.04.09 于 Shenzhen

2014/4/7
articleCard.readMore

记一次当前工作目录问题的排查经历

最近在使用ClearCase的时候遇到一个问题,当从命令行里启动版本树,并想给一个节点打上review属性时,经常会出现一个命令窗口一闪而过,刷新版本树之后却没能找到想要打的review属性,只有再次尝试才会正确打上。大家忍受了这个问题很久,但一直都没时间去深入分析它。在连续几次遇到这情况之后,我觉得忍无可忍,下定决心解决它,最终找到了问题的根源并给出了解决方案,在这里详细记录一下这次排查的经历。 目录 故事背景 创建unittested/reviewed属性 问题浮现 分析过程 猜测一 猜测二 猜测三 看到曙光 拔开云雾 其它问题 解决方案 一波三折 Workaround 针对checkout文件 针对直接使用脚本的情况 测试 写在结尾 故事背景 我们使用的版本管理工具是ClearCase,一个集中式(相对于分布式的Git)的商业化配置管理工具,类似于SVN,CVS等工具,但功能强大的多,有很强的扩展性,可以根据自己的需要进行一些订制与扩展,比如增加一些提交代码时的trigger,checkout代码时hook等等。当然,价格也不菲。 clearvtree是用来查看一个文件版本树的工具,类似于git中的gitk -- aFile,可以非常方便的查看单个文件的修改历史。它可以用下面两种方式打开: ClearCase资源管理器,和Windows资源管理器很相似 命令行,直接输入clearvtree file_path 因为流程方面的要求,在每次提交一份代码后,必须经过相应的单元测试,由提交者打上unittested属性,然后交给他人review,如果没问题,他会打上reviewed属性,否则提交者需要再重复这一过程只到问题解决为止。只有在这两份属性共同存在的情况下,新版本才能被允许进入build中,这从很大程度上保证了代码的质量。 创建unittested/reviewed属性 使用cleartool mkattr命令可以创建这些属性,但由于这两个属性必须带上一些有用的信息,比如时间,执行者,所以为了方便起见,我们通常不直接调用,而是采用一个脚本reviewed_by_me.bat(这里只讨论review,unittest的处理完全一样)去得到执行者与当前时间的信息,这个脚本里面的内容大致如下: set element="%1" rem get time for var %time% ... rem get user for var %user% ... call cleartool mkattr reviewed_by_%user% \"%time%\" %element% ... 然后在Windows的SendTo目录下创建一个快捷方式reviewed,指向这个脚本。于是,要给一个文件创建属性时,只需: 用前面介绍的任一种方式启动版本树 在版本树中对目标节点点击右键,然后在SendTo菜单下选择创建的reviewed选项 问题浮现 一切都显得很正确,但用了一段时间之后,很多人发现一个现象,打开版本树后,经常点了reviewed选项,发现一个命令窗口一闪而过,刷新却看不到刚创建的属性,然后只有再试一次才能成功,更为奇怪的是这个问题并不总是出现。 分析过程 要解决这个问题,这里有三点需要解答: 为什么问题不是每次都出现? 为什么第一次没有成功,出了什么错误? 为什么刷新一次之后就可以了? 这里首先看看第一次到底出了什么错误。由于命令窗口一闪而过,无从知道发生了什么,所以要么重定向脚本,要么让脚本执行完之后停下,而不是关闭退出,这样才能够得到错误信息,这里采用了简单的暂停脚本方案。找到脚本文件,在最后加上pause,再试一次创建属性,得到了错误信息(注:这里假设文件是:Q:\project\src\test.cpp,版本是main\7,所以在ClearCase里面整个文件的路径为Q:\project\src\test.cpp@@\main\7): cleartool: Error: Unable to access "project\src\test.cpp@@\main\7": No such file or directory. 找不到文件?在命令行中运行: Q:\> ls project\src\test.cpp project\src\test.cpp 明明文件存在,怎么会显示找不到这个文件呢? 猜测一 想到之前出现过在ClearCase中无法找到文件的情况:访问大量文件时偶尔会出现无法找到个别文件的情况,那是由于网络的问题。因为ClearCase采用的是集中式的版本控制,我们创建view的方式是Dynamic,而不是Snapshot,所以所有的数据实际上存在于ClearCase服务器上,客户端想要访问一个文件时,会通过网络协议去服务器获取,取回本地之后才能够访问。如果出现大量的文件访问,网络又不是特别好的情况下,就可能会出现传输失败,从而无法访问文件的情况。 于是用ccadminconsole.msc命令去查找ClearCase所有的Log,结果真在ClearCase\My Host\Server Logs\view下找到了一些可疑的Log: Reloading view cache; expect temporary delays accessing objects in VOB XXX 难道是这个原因?去网上搜索一阵也无法得到有用的信息。 但是转眼又想,既然刚刚是由于这个文件还没有取回本地导致的,如果我再次打开版本树,并尝试创建review属性,是否就应该没问题了?抱着这种想法又试了一次,结果还是和刚才情况一样,无法找到文件,于是否定了这个猜测。 猜测二 放弃了上面的猜测之后,又进行另一种猜测,会不会是SendTo的机制有些没弄明白的地方?如果不用SendTo的这种方式,而直接用前面介绍的命令,会有一样的结果么?于是在命令行中调用: Q:\> cleartool mkattr reviewed_by_xxuser \"20140306_0952\" project\src\test.cpp@@\main\7 Created attribute "reviewed_by_xxuser" on "project\src\test.cpp@@\main\7". 竟然成功执行,那么问题可能就出现在SendTo上。接着猜想,难道是由于SendTo的实现机制有问题?如果不用SendTo,而是直接在命令行里调用这个脚本可以么?于是把上面的命令保存成一个脚本,放在C:\test\reviewed_by_me.bat,再调用一次: Q:\> C:\test\reviewed_by_me.bat Created attribute "reviewed_by_xxuser" on "project\src\test.cpp@@\main\7". 仍然成功。 等等,这和SendTo的方式还有一点区别,SendTo是的确是调用了这个脚本,但是它是通过一个快捷方式来调用的,而不是直接运行脚本。为了达到一样的实验条件,我也创建一个快捷方式:reviewed,指向上述脚本。双击之后,发现果然出错了,一样的无法找到文件! 现在的问题就变成了双击以快捷方式打开的脚本和直接从命令行里启动的脚本有什么区别?也许大家看到这里就能猜到原因了,但是当时我还没有立刻意识到,而是同时思考了另外一个问题,为什么把版本树刷新一次又可以了呢?刷新前后都调用的是同样的SendTo,这次刷新前后有些什么区别? 猜测三 为了得到更多的信息,将reviewed_by_me.bat中的命令执行前都打印输出,执行两次之后注意到了问题所在。这是第一次失败时的结果: call cleartool mkattr reviewed_by_xxuser \"20140306_1002\" project\src\test.cpp@@\main\7 cleartool: Error: Unable to access "project\src\test.cpp@@\main\7": No such file or directory. 这是刷新之后成功执行的结果: call cleartool mkattr reviewed_by_xxuser \"20140306_1003\" **Q:\\**project\src\test.cpp@@\main\7 Created attribute "reviewed_by_xxuser" on "**Q:\\**project\src\test.cpp@@\main\7". 细心的你可能已经发现了这个区别,成功的那一次多了一个Q:\,是一个绝对路径,失败的是相对路径,难道问题就出在这里?那为什么前面直接在命令行中用这个相对路径也能正确执行呢? 这个路径是怎样来的? 在前面调用clearvtree时用的是: Q:\> clearvtree project\src\test.cpp 难道是这个原因?于是我试了一次输入绝对路径给clearvtree: Q:\> clearvtree Q:\project\src\test.cpp 然后再创建一次review属性,真的成功了! 看到曙光 通过前面的猜测,现在的问题就定位在相对路径与绝对路径上,文章刚开始的三个问题变成: 为什么相对路径会出错? 为什么同样是相对路径,在命令中的调用不出错,而通过SendTo就出错了? 为什么刷新之后相对路径变绝对路径了? 我们知道任意一个进程在处理一个相对路径时,为了正确的访问文件,都需要另一个重要的参数:当前工作目录,进程,更准确的说是操作系统会将相对路径扩展成一个绝对路径再进行处理: 绝对路径 = 当前工作目录 + 相对路径 拔开云雾 前面都是我们的推测,到了该印证推测的时候了。 从命令行中的调用和SendTo的方式结果不一致入手。 由于它们的相对路径一样,那么问题一定是出在当前工作目录上,先来看命令行的方式,从头到尾我只开了一个命令行窗口,它的工作路径是:Q:\,因此,在这里面调用的子进程默认都会继承同样的当前工作目录,所以传给它们的相对路径最终都会扩展成为正确的路径: path = Q:\ + project\src\test.cpp = Q:\project\src\test.cpp 再来看SendTo,因为这里调用的是一个快捷方式,它有一个特点,可以指定自己的Start in参数,下图是SendTo中reviewed快捷方式的属性: 这里的Start in属性指定的就是调用命令时的当前工作目录,它的值是M:\admin\tools\review,因此一个相对路径扩展之后变成: M:\admin\tools\review\project\src\test.cpp 这个路径当然是错误的,这就可以解释为何SendTo的方式会出错了。 其它问题 前面介绍过打开版本树通常有两种方式,除了刚刚讨论的命令行,还可以从ClearCase资源管理器中打开,这种情况不存在我们讨论的问题,因为点击打开版本树选项之后,资源管理器直接将完整的路径传给了clearvtree进程,因此不管它的当前工作目录位于何处,都可以正确的处理。这也可以解释文章最开始提出的问题:为什么这个问题有时出现,有时不出现? 对于问题3,没有找到相关的资料,推测可能是由于刷新之后,clearvtree进程内部将路径扩展,这是程序内部实现的问题,在这里不作讨论。 解决方案 找到问题之后,应该如何解决呢?想到两种解决思路: 把当前工作目录设置成与clearvtree一样 在reviewed_by_me.bat脚本中将相对路径扩展成绝对路径 对于方案二,打算扫描每个view,然后去匹配路径,但这样速度一定很慢,而且还无法保证正确性,放弃。 那么只有采用方案一,现在的问题变成,如何获得clearvtree的当前路径?在reviewed调用脚本的时候,只能从clearvtree那里获得文件的相对路径,存储在%1中。工作目录从哪里获取到呢? 在前面讨论中,一个进程调用子进程时,默认情况下子进程的当前工作目录会与父进程一样,什么是默认情况?其实就是子进程没有明确指定自己当前工作目录的情况,而这里的reviewed快捷方式是设置了Start in,这样就意味着如果将它清空,脚本便会从clearvtree中获得正确的当前工作目录,根据这个分析开始验证: 清空reviewed快捷方式的Start in 在reviewed_by_me.bat脚本中输出当前工作目录: for /f %%i in ('CD') do echo %%i 一波三折 本来期盼着得到:Q:\,结果却出乎意料,输出的是: Q:\project\src\test.cpp@@\main 这根本不是一个目录,不知为何clearvtree将脚本的工作路径设置成了这样一个奇怪的”目录”。但是不管怎么样,起码我们可以得到一个大概的路径,知道它在Q:\盘下,现在的相对路径是: project\src\test.cpp@@\main\7 可以从前面的当前工作目录中得到盘符,然后去猜测相对路径,或者拿相对路径与当前工作路径去匹配,计算得到一个正确的路径…… 这种方法可以得到正确的结果,但是有些复杂,最终没有采用,因为发现了一种更为简单的workaround。 Workaround 上面的奇怪”目录”是怎么来的,注意看该节点完整的路径: Q:\project\src\test.cpp@@\main\7 对比可以发现,其实是由于操作系统不知道ClearCase采用的文件命名方式: 绝对路径 + @@ + \分支\ + 版本号 操作系统将上面ClearCase内部路径当成了一个普通的文件路径,版本号7被当成了文件名,而从右边开始的第一个版本号分隔符\被当成了路径分隔符,操作系统去除”文件名”,所以才出现了这种奇怪的”目录”。 为了印证这一点,我又对另外一个节点创建reviewed属性: project\src\test.cpp@@\main\test\1 这是在main上的一个test分支,再次选择SendTo下的reviewed快捷方式,得到下面的当前工作目录: Q:\project\src\test.cpp@@\main\test 看到这里想必大家都明白我要做什么了,没错,这个奇怪的”目录”其实就差一个版本号就可以构成一个完整的节点路径,而clearvtree传过来的%1参数就包含了这个版本号,截取之后拼在上面的目录后就可以得到完整的路径,这里是最终的reviewed_by_me.bat代码: set element="%1" rem get time for var %time% ... rem get user for var %user% ... rem Get the version number for /f %%i in ("%element%") do set version_num=%%~ni rem Assemble the real path for /f %%i in ('CD') do set element_path=%%i\%version_num% call cleartool mkattr reviewed_by_%user% \"%time%\" %element_path% ... 针对checkout文件 (2014.06.06更新) 因为有时并不是只对已经archive的节点进行review和unittest,而是会直接操作于checkout文件。那么此时便没有了上面的版本号一说,直接使用上面的代码会出现问题。还是用上面的例子来说明: Q:\> clearvtree project\src\test.cpp 此时,假设test.cpp是checkout的文件,我们想对其增加review和unittest属性。这里得到的当前工作目录是: Q:\project\src 路径完全正确,没有了前面@@main引起的干扰。所以文件的完整路径就可以这样得到: 当前工作目录 + \test.cpp 但是如果直接用前一节的reviewed_by_me.bat脚本,却会得到这样的结果: Q:\project\src\test 这里少了后缀名,为什么呢?问题出在代码中的set version_num=%%~ni,因为%~ni只表示文件名,因此只有test出现。如果想要取得完整的路径,那么只用再加上后缀名即可,即将set version_num=%%~ni改成set version_num=%%~nxi,仅仅增加一个x。因为这种情况下后缀名为空,因此也不会影响前面说的archive文件。 所以,最终版本的reviewed_by_me.bat代码变为: set element="%1" rem get time for var %time% ... rem get user for var %user% ... rem Get the version number for /f %%i in ("%element%") do set version_num=%%~nxi rem Assemble the real path for /f %%i in ('CD') do set element_path=%%i\%version_num% call cleartool mkattr reviewed_by_%user% \"%time%\" %element_path% ... 针对直接使用脚本的情况 (2014.08.22更新) 因为有时会不使用SendTo快捷方式来打标签,而是直接调用脚本,此时,当前工作目录可能会出错,为了避免这种情况,脚本又做了以下更新: set element="%1" rem get time for var %time% ... rem get user for var %user% ... rem Get the version number for /f %%i in ("%element%") do set version_num=%%~nxi rem Assemble the real path for /f %%i in ('CD') do ( set element_path=%%i\!version_num! rem If the assembled file does not exist, then there are two possibilities: rem 1. Wrong path rem 2. The batch file is called directly rather than the shortcut rem Both the above should use the argument directly if NOT EXIST !element_path! set element_path=!sw_element! ) call cleartool mkattr reviewed_by_%user% \"%time%\" %element_path% ... 测试亦作了相应的更新。 测试 最后对下面几种方式启动的版本树进行了测试,所有的情况都成功的运行: Q:\> clearvtree project\src\test.cpp Q:\project> clearvtree src\test.cpp Q:\project> clearvtree ..\project\src\test.cpp Q:\project> clearvtree \project\src\test.cpp Q:\project\src> clearvtree test.cpp C:\> clearvtree Q:\project\src\test.cpp 直接从ClearCase Explorer中打开版本树 针对已经checkout的文件 直接使用bat脚本而不是快捷方式 至此,该问题成功解决。 写在结尾 这是一次平常众多工作中遇到的一个细小的问题,通过一点点的排除,顺藤摸瓜,最终找到了问题的根源。这篇文章非常详尽(啰嗦似乎更合适)的通过自问自答的方式,记录了整个分析过程。对我来说,重要的不是解决了这个问题,而是训练自己遇到事情不去忍受,想办法解决的意识。 记得刚刚参加工作时,由于要分析程序崩溃的原因,用一个命令每次一行的分析backtrace文件,通过内存地址去得到堆栈的确切位置。每次都需要拷贝地址,修改命令参数,执行,通常要执行五六次以上才能够找到有用的信息。但当时好像每个人都认同这种方式,不就是简单的几步操作么,没觉得有多麻烦啊,我也一样。后来一个哥们写了一个简单的shell脚本,输入为backtrace文件,一次把所有的内存地址转换成文件的确切位置,看到这个脚本时立刻被震撼到了,不是这个脚本有多复杂,而是除了他,没有任何人想到这点,所有人都在忍受,甚至连忍受都感受不到,麻木的接受一切。 其实每一份忍受都源自于对现状的不满足,各行各业存在的目的不就是为了解决人们各种各样的不满足么,一个公司能否持续发展不也是看它能否不断发现并满足人们的不满足么?换种说法,这种不满足也就是需求。面对需求我们应该做些什么?发现并抓住它,有可能就成为机遇;忽略它,可能就变成了抱怨。人的追求是无止尽的,现状永远都满足不了人,所以我一直认为,只要存在不满足的地方,就一定存在着机遇,问题在于我们怎样面对它们。我们领导经常说这样一句话:别抱怨,提建议;别建议,解决它。我很喜欢这句话,送给大家,与君共勉,做一个有心人。 (全文完) feihu 2014.03.07 于 Shenzhen

2014/3/7
articleCard.readMore

谁打印了这个字符串

前段时间在调试时遇到一个问题,运行程序出现错误,但并没有足够的信息来定位错误所在。可喜的是控制台上输出了一些可疑信息,只要找到了哪里打印了这些信息便有可能推断错误的原因。然而由于程序过于庞大,不可能一步一步跟踪调试去查找哪条语句执行后输出了这段字符串。尝试在所有的代码中搜索这段字符串也无功而返。后来突发奇想,能否在输出字符串时设置一个条件断点,只要输出的这段信息就中断,这样就可以在中断后找到何处打印了这些可疑信息,进而解决程序的问题了。 经过大量的搜索之后,在Stack Overflow上找到了答案,并且成功的解决了我的问题。被采用答案的作者Anthony Arnold由于十分喜欢这个问题,所以写了一篇关于它的博文。我也特别喜欢这个问题,之前遇到过多次,但都采用别的方式解决,而只有这个答案最完美。同时它综合运用了很多知识,能够给我们的调试带来不少启发。因为网上也没有再找到其它的解决方案,所以我决定翻译此文,后面对原文再进行其它平台的补充。离开学校之后第一次翻译,不好之处欢迎指正。 目录 调试STDOUT 例子 在write中设置断点 在write中写到STDOUT时设置断点 获取系统调用参数 限定到特定字符串 64位系统的解决方案 总结 再进一步 Windows上的解决方案 可移植方案 只适用Win32的方案 只适用x64的方案 写在结尾 调试STDOUT 前几天我遇到一个很有趣的Stack Overflow 问题,提问者希望GDB能够在一个特定的字符串写到stdout时中断程序。 除非你至少掌握了一些下面的知识,否则并不容易得到这个问题的答案: 汇编语言 操作系统 C语言标准库 GDB命令 我想出了一种可行的但是不可移植的解决方案,它依赖于x86指令集和Linux的系统调用write(2),所以本文的讨论限制在特定操作系统内核上的特定架构。 例子 我们使用下面的代码(定义在hello.c中)来演示如何用GDB在"Hello World!\n"写入stdout时中断。(feihu注:代码作了一定的修改,增加了另外一个输出字符串的函数,以更好的演示捕获特定字符串) #include <stdio.h> void test() { printf("Test\n"); } int main() { printf("Hello World!\n"); test(); return 0; } 用下面的命令编译链接: # gcc -g -o hello hello.c 用下面的命令调试: # gdb hello 在write中设置断点 第一步,我们需要找出如何在有数据被写到stdout时中断程序。我们假设你调试代码的作者没有疯,他们采用了所选语言的标准用法来向stdout写数据(比如C语言中的printf(3)),或者他们直接调用系统调用write(2)。 实际上最终printf(3)也调用的是write(2),所以不管采用上面哪种方式都可以。 因此你可以在write(2)系统调用中设置一个断点: $gdb break write GDB也许会抱怨它并不知道write函数,但是它可以在将来这个函数有效时再在其中设置这一断点: Function "write" not defined. Make breakpoint pending on future shared library load? (y or [n]) 这完全没问题,直接输入y即可。 在write中写到STDOUT时设置断点 一旦你能够在write函数中设置断点后,你需要设置一个条件,只有在写到stdout时才中断,这里有一点复杂。看看write(2)的帮助页面:第一个参数fd是要写的文件描述符,在Linux中,stdout的文件描述符是1(也许你使用的平台有所不同)。 于是你的第一反应是这样: $gdb condition 1 fd == 1 但是很遗憾,这不起作用。(feihu注:会出现这样的错误No symbol "fd" in current context.)除非你非常幸运,否则不可能已经加载了write(2)系统调用的调试symbols,这意味着你不能以参数名来访问传给系统调用的参数。 获取系统调用参数 当然,还有其它的方法可以获取传给系统调用的参数,GDB提供了非常完善的手段让你来访问各种汇编寄存器,在这个问题中,我们感兴趣的是Extended Stack Pointer,也就是esp寄存器。(feihu注:这里仅适用于x86 32位系统,x86 64位系统的解决方案请向下看。) 当一个函数调用发生时,栈中储存了: 函数的返回地址 指向函数参数的指针 这意味着当调用write函数时,栈的结构可能像下面一样: 这是假设地址占4个字节,根据你自己的机器作相应的调整 所以现在你可以像这样设置条件断点: $gdb break write if *(int *)($esp + 4) == 1 再一次声明,假设地址占4个字节 注意,$esp能够访问ESP寄存器,它将所有的数据都看成是void *。因为GDB不允许直接将void *转换成int型(这么做是对的),所以你需要先将($esp + 4)转换成int *,然后再对其指针取值以获得文件描述符参数的值。 限定到特定字符串 接下来更加复杂一点,它是上一步的扩展,而且,它并不适用于所有的情况。传给printf(3)的字符串并不一定会完整的传给write(2),它可能会被分成更小的块然后一次性的写到文件中,调试stdout时请记住这一点,当然如果用短字符串的话应该没问题。(feihu注:调用printf时并不会每次都调用write,而是会先把数据放到缓冲区,等缓冲区积累到一定量时才会一次性的写到文件中。如果想到立即写到文件中的话,需要调用fflush。) 现在你必须再将这个断点加上一定的限制,当且仅当一个特定的字符串传给write(2)时才中断程序。为了做到这一点,你可以对write的第二个参数buf调用strcmp(3)。从前面的表中可知,$esp + 8指向的就是buf,所以再给断点增加一个条件就变得很简单了: $gdb break write if *(int *)($esp + 4) == 1 && strcmp("Hello World!\n", *(char **)($esp + 8)) == 0 请记住,$esp + n * sizeof(void *)就代表了函数第n个参数的指针,这表明$esp + 8指向的是一个指向字符串的指针,为了让它能够正确的运行,你需要做一些转换和取值操作。 64位系统的解决方案 64位系统需要采用RDI和RSI寄存器。(feihu注:对于32位系统,所有的函数参数是写在栈里面的,所以可以用前面介绍的办法。但是64位系统中函数的参数并未存放在栈中,它提供了更多的寄存器用于存放参数,请戳这里。) $gdb break write if 1 == $rdi && strcmp((char *)($rsi), "Hello World!\n") == 0 注意这里没有了指针的转换操作,因为寄存器里面存的不是指向栈中元素的指针,它们存的是值本身。 总结 经过上面的一系列步骤之后,你可以得到一个移植性不好的解决方案,在一些特定的平台和架构下,可以使用GDB在一个特定的字符串写到stdout时中断程序。 如果你有一个更好的解决方案,或者仅仅是另外一个替代方案,请在下面留言,并且/或者直接在Stack Overflow上回答这个问题。还有,如果你有一种方案可以工作在其它平台或者架构下,请一定也给我留言。 再进一步 原文的翻译就到上面,但是这里还可以再做一些改进。比如上面的strcmp函数可以用下面的函数来代替: strncmp对于你只想匹配前n个字符的情况非常有用 strstr可以用来查找子字符串,这个非常有用,因为你不能确定你要查找的字符串到底是完整的一次由write输出的,还是经过几次printf在缓存区合并之后才写到控制台的,因此我更加倾向这个方法 Windows上的解决方案 随后我想,能不能在Windows平台上也使用类似的方法,最终也成功了(x86-64位操作系统下的Win32和64位程序)。 对于所有的写文件操作来说,Linux最终都会调用到它的POSIX API write函数,Windows和Linux不一样,它提供的API是WriteFile,最终Windows上的调用都会用到它。但是,不像开源的Linux可以调试write,Windows无法调试WriteFile函数,所以也无法在WriteFile处设置断点。 但微软公开了部分VC的源码,所以我们可以在给出的源代码部分找到最终调用WriteFile的地方,在那一层设置断点即可。经过调试,我发现最后是_write_nolock调用了WriteFile,这个函数位于your\VS\Folder\VC\crt\src\write.c,函数原型如下: /* now define version that doesn't lock/unlock, validate fh */ int __cdecl _write_nolock ( int fh, const void *buf, unsigned cnt ) 对比Linux的write系统调用: #include <unistd.h> ssize_t write(int fd, const void *buf, size_t count); 每个参数都可以对应的起来,所以完全可以参照上面的方法来处理,在_write_nolock中设置一个条件断点即可,只有一些细节不一样。 可移植方案 很神奇的是Windows下面,在设置条件断点时可以直接使用变量名,而且在Win32和x64下面都可以。这样的话调试就变得简单了很多: 在_write_nolock处增加一个断点 注意:Win32和x64在这里有些许不同,Win32可以直接将函数名作为断点的位置,而x64如果直接设置在函数名处是无法中断的,我调试了一下发现原因在于x64的函数入口处会给这些参数赋值,所以在赋完值之前这些参数名还是无法使用的。我们这里可以有一个work around:不在函数的入口处设置断点,设置在函数的第一行,此时参数已经初始化,所以可以正常使用了。(即不用函数名作为断点的位置,而是用文件名+行号;或者直接打开这个文件,在相应的位置设置断点即可。) 设置条件: fh == 1 && strstr((char *)buf, "Hello World") != 0 注意:但是这里有一个问题,我测试了printf和std::cout,对于前者,所有的字符串一次都写到了_write_nolock中,然而std::cout是一次传一个字符,这样也就无法使用后面比较字符串这个条件了。 只适用Win32的方案 当然,这里我们也可以采用前面介绍的方法,通过寄存器来设置断点的条件。同样由于平台的差异,这里分Win32和x64来讨论。 对于Win32程序,和前面介绍的Linux下使用GDB的方法基本一致。注意到函数前面的__cdecl,这种调用约定表明: 参数由右至左传递 调用方负责清理栈 非特殊情况下,会在函数名前加下划线来修饰 不执行任何大小写转换 可以参考这里。微软的网站上还有一个例子来展示函数调用时参数在栈中的情况,结果请看这里,它给出的每一种调用约定下寄存器和栈的使用情况。 知道这些之后,设置一个条件断点就变得非常容易了: 在_write_nolock处设置一个断点 增加条件: *(int *)($esp + 4) == 1 && strstr(*(char **)($esp + 8), "Hello") != 0 同前面介绍的Linux下的方法一样,条件的第一部分是对fh,只有当向stdout写数据时才中断。第二部分是针对buf,当其中含有特定的字符串时满足中断条件。 只适用x64的方案 从x86到x64有两个重要的改变,一是地址容量从32位变成了64位,二是增加了一些64位寄存器。因为这些寄存器的增加,x64就只使用__fastcall这种方式作为调用约定,这种方式会将前四个参数放到寄存器中,如果有更多参数的话,会存到栈中。 关于参数传递可以参考这里,函数调用时前四个参数会依次放到:RCX、RDX、R8和R9当中,因此增加条件断点也变得很容易: 在_write_nolock处设置一个断点: 注意:这里直接在入口设置即可,不需要像前面可移植方案中介绍的那样设置到函数的第一行,因为寄存器在入口处已经有了正确的值。 设置条件: $rcx == 1 && strstr((char *)$rdx, "Hello") != 0 由于esp是将所有的数据都看成是void *,所以需要做一定的转换才可以使用。而这里的寄存器存的是参数的值,所以不需要这个转换。所以rcx本身就是fh的值,而rdx中存的是一个指针,将它转成char *之后即可表示字符串。 写在结尾 同样,这个方法也有一定的局限性,比如对于std::cout,我还没有找到好的解决办法。 我也将这后面Windows上的方法发到Stack Overflow和原文的评论中,算是给这个问题的一个扩展吧。如果你有更好的解决方案,请给我们留言或者直接在Stack Overflow回答。 (全文完) feihu 2014.01.16 于 Shenzhen

2014/1/15
articleCard.readMore

跟我一起学习VIM - The Life Changing Editor

前两天同事让我在小组内部分享一下VIM,于是我花了一点时间写了个简短的教程。虽然准备有限,但分享过程中大家大多带着一种惊叹的表情,原来编辑器可以这样强大,这算是对我多年来使用VIM的最大鼓舞吧。所以分享结束之后,将这篇简短教程整理一下作为我2014年的第一篇Blog。 目录 写在前面:Life Changing Editor 什么是VIM 为什么选VIM 为什么选其它 为什么犹豫选择它们 VIM >= SUM(现代编辑器) 如何学习VIM 一秒钟变记事本 VIM的基本用法 VIM进阶:插件 插件管理神器:Vundle 配色方案 导航与搜索 自动补全 语法 其它 终极配置: spf13 与其它软件集成 一些资源 写在最后 搭完网站之后的第一篇文章有些兴奋,先变身话痨简单回顾一下我是如何接触到VIM的,不感兴趣的同学可以直接跳过这一部分:-) 写在前面:Life Changing Editor 我是一个非常懒的人,对于效率有着近乎执拗的追求。比如我会花2个小时来写一个脚本,然后使用这个脚本瞬间完成一个任务,而不愿意花一个小时来手工完成这项任务,从绝对时间上来说,写脚本花的时间更长,但我依然乐此不疲。 工欲善其事,必先利其器,折腾各种各样的软件就成为了我的一大爱好,尤其是各种人称神器的工具类软件,而善用佳软是这类工具的聚集地,现在我使用的很多优秀的软件都得知于此,包括VIM,所以,如果你和我一样,希望拥有众多“神器”,让工作事半功倍,可以关注此站。 第一次听说VIM已经是离开校园参加工作之后的事,那时部门内部大多使用Source Insight代替Visual Studio编写代码,大家都被它的代码管理,自动完成,代码跳转等功能所吸引,但一个领导说了句很多Vimer经常会说,至今仍让我记忆尤新的一句话: 世界上只有三种编辑器,EMACS、VIM和其它 我很反对这种极端的言论,使用何种工具是一个人自由,只要能发挥一个工具最大的效率就行,不应该加以约束,更不应该鄙视。话虽如此,我却阻挡不住好奇心的驱使,琢磨着到底是什么样的编辑器会拥有这样高的评价。抱着这份好奇,我搜索到了善用佳软,看到《普通人的编辑利器——Vim》,Dieken的《程序员的编辑器——VIM》,以及王垠的《Emacs是一种信仰!世界最强编辑器介绍》BANG……想到不久前看到的一段话: 南中国的雷雨天有怒卷的压城云、低飞的鸟和小虫,有隐隐的轰隆声呜呜咽咽……还有一片肃穆里的电光一闪。那闪电几乎是一棵倒着生长的树,发光发亮的枝丫刚刚舒展,立马结出一枚爆炸的果实,那一声炸响从半空中跌落到窗前,炸得人一个激灵,杯中一圈涟漪。 这种一个激灵的感觉不仅仅局限于雷雨天。在我读完上面几篇文章之后,简单的文字亦立刻击中儃中,炸的一个激灵。从此,我对编辑器的认识被完全颠覆。 很多孩子都有一个梦想:希望能够长大之后可以身着军装,腰插手枪,头戴警帽,遇到坏人之后潇洒拔出枪,瞬间解决战斗,除暴安良,匡扶正义。我这样的程序员们也有一个梦想:希望学成之后可以像电影里黑客们一样,对着满屏幕闪烁的各种符号,双手不离键盘噼里啪啦一阵乱敲,屏幕上的符号不断滚动,就攻破了几百公里之外的某某银行的服务器,向帐户里面增加一笔天文数字,然后潇洒的离去,神不知鬼不觉,留下不知所措的孩子们的梦想——警察叔叔们。这简直构成了程序员们的终极幻想:-P。VIM的出现让我感觉离幻想更近了一步,呃,别想错了,我是指——双手不离键盘,噼里啪啦,黑客的范儿。不可否认,扮酷也是促使我学习VIM的一个重要原因:-P。 在一个激灵之后,接下来便是不可自拔的陷入VIM世界,于是网上搜索各种入门教程,_vimrc的配置,折腾插件,研究奇巧淫技,将VIM打造成IDE。那感觉就像世界从此就只有VIM,写代码用VIM,Visual Studio用VIM,Source Insight用VIM,甚至写PDF,浏览网页都要用VIM,够折腾吧。可是像Vimer们一样,我依然折腾着,并快乐着。如今,折腾一圈之后,随着对Unix的KISS设计哲学逐渐理解与认可:把所有简单的事情做到极致。所以在对待VIM的态度上也有了一定的转变,不再执著的将它打造成万能的IDE,而仅仅让它将编辑功能发挥到极致,其它的事情交给其它更擅长的工具去做。Keep It Simple, Stupid. 在VIM的官方网站上,对每个插件的评价是这样分类的: Life Changing Helpful Unfulfilling 而我想将这个分类应用到使用的软件上,对于VIM,它是毫无疑问的Life Changing。 什么是VIM 以下两句对编辑器的最高评价足矣: VIM is the God of editors, EMACS is God’s editor EMACS is actually an OS which pretends to be an editor 为什么选VIM 我们所处的时代是非常幸运的,有越来越多的编辑器,相对于古老的VIM和EMACS,它们被称为现代编辑器。我们来看看这两个古董有多大年纪了: **EMACS** : 1975 ~ 2013 = 38岁 **VI** : 1976 ~ 2013 = 37岁 **VIM** : 1991 ~ 2013 = 22岁 看到这篇文章的人有几个是比它们大的:-) VIM的学习曲线非常陡,这里有一个主流编辑器的学习曲线对比。既然学习VIM如此之难,而现代编辑器又已经拥有了如此多的特性,我们为什么要花大量的时间来学习这个老古董呢? 为什么选其它 先来看看为什么我们会选现在所使用的编辑器?(也许很多人直接用IDE自带的编辑器,我们暂且也把它们划到编辑器的范畴内。)这里我简单列举一些程序员期望使用的编辑拥有的功能: 轻量级,迅速启动(相对于IDE) 特性 语法高亮 自动对齐 代码折叠 自动补全 显示行号 重定义Tab 十六进制编辑 列编辑模式 快速注释 高级搜索,替代 错误恢复 迅速跳转 Mark 也许,美观也是一个诉求 但是… 为什么犹豫选择它们 总有一些理由让我们一再犹豫的选择它们,或者勉强使用它们: 太贵:虽然知道VS很贵,但看到价格时,还是被吓了一跳 Visual Studio Profession 2012 : 11645元 UtralEdit : 420元 Source Insight : 2500元 $$ $$ $$ 不能跨平台 VS, SI, UE,Notepad++这些只能在Windows上使用 Mac上的TextMate只能运行于Mac上 不容易扩展 那么,还有别的选择么? VIM >= SUM(现代编辑器) 首先,VIM包含了上面列的所有现代编辑器的优点,并且远远多于此。 并且,VIM拥有让你不再犹豫的其它特性: 无止尽的扩展:现在VIM的官方网站上已经有了4704个扩展,并且在不断增加… 完美的跨平台: Windows : gVim Linux : 内置默认 (e.g., man page) Mac : MacVim 开源 用起来很酷 最关键的,\($**免费**\)$ 废话结束,开始进入正题。 如何学习VIM 一秒钟变记事本 很多时候大家希望能够以最快的速度编辑文档,而不愿意花大量的时间在学习这一工具上,比如偶尔要去Linux改变一下配置。这时VIM有一种方法可以一秒钟变记事本,打开VIM之后,只需要一个键i,接下来所有的操作就和Windows上的记事本无异,你所喜爱与习惯的方向键也回来了。 这也并没有多神奇,它只是VIM提供的一种特殊的模式:Insert mode,在按过i之后,你可以在编辑器的左下角看到INSERT字样。但是因为VIM无法使用CTRL-S来保存,那么,在编辑完之后,如何保存退出呢?也很简单,先按ESC,再输入:wq,前面一步是告诉VIM退出INSERT模式,后面一个命令是保存退出。 我见过很多人这样用,虽然说这很容易,但是有种暴殄天物的感觉,和给了你一把AK47,你却把它当成棍子使一样。要发挥AK47的作用,还请向下看。 VIM的基本用法 最好的入门教程非VIM自带的vimtutor莫属,它是VIM安装之后自带的简短教程,可以在安装目录下找到,只需半个小时左右的时间,就可以掌握VIM的绝大部分用法。这是迄今为止我见过的软件自带教程中最好的一个。 当然,网上的VIM教程也非常多,我之前看的是李果正的大家来学VIM,很适合入门。 另外推荐陈皓的简明VIM练级攻略,或者创意十足的游戏VIM大冒险。 这游戏的创意实在是太赞了,打完游戏,你便掌握了VIM,这才是真正的寓教于乐,下面是摘自这个游戏的描述: VIM Adventures is an online game based on VIM’s keyboard shortcuts (commands, motions and operators). It’s the “Zelda meets text editing” game. It’s a puzzle game for practicing and memorizing VIM commands (good old VI is also covered, of course). It’s an easy way to learn VIM without a steep learning curve. 最后在这里给大家分享一个vgod设计的VIM命令图解。这也是我看过的最好的命令图示,看完了前面的基本教程后,可以将它作为一个cheat sheet随时查看,相信用不了多久你也可以完全丢掉它。关于此图的详细解释可以参考这里。 VIM进阶:插件 在学完了上面任何一个教程之后,通过一段时间的练习,你已经可以非常熟练的使用VIM。即使是“裸奔”,VIM已经足够强大,能够完成日常的绝大部分工作。但VIM更加强大的是它的扩展机制,就像Firefox和Chrome的各种插件,它们将令我们的工具更加完美。网上有很多教程里写的插件已经过时,接下来我将介绍一些比较新的,非常有用的插件,看完之后,相信你一定会觉得蠢蠢欲动。 插件管理神器:Vundle 在这开始之前,先简单介绍VIM插件的管理方式。在我刚接触插件之时,安装一个插件需要: 去官网下载 解压 拷贝到VIM的安装目录 运行:help tags 这些步骤已经足够复杂,更加无法想象的是要更新或者删除一个插件时,因为它的文件分布在各个目录下,就比如Windows上的安装路径,Application data,用户数据,注册表等等,除非你对VIM的插件机制和要删的插件了如直掌,否则你能难将它删除干净。所以一段时间之后,VIM的安装目录下简直就是一团乱麻,管理插件几乎成为了一项不可能完成的任务。想象一下,如果Windows上面没有软件管理工具,你如何安装,卸载一个软件吧。 但是这没有难倒聪明的Vimer们,他们利用VIM本身的特性,开发出了神器——Vundle,配合上GitHub,VIM插件的管理变得前所未有的简单。来对比一下使用Vundle如何管理插件: 在按照官方的教程安装好Vundle之后,要安装一个插件时,你只需要: 选好插件 在VIM的配置文件中加一句 Bundle 'your/script/path' 在VIM中运行 :BundleInstall 卸载时只需: 去除配置文件中的 Bundle 'your/script/name' 在VIM中运行 :BundleClean 更新插件就更加简单,只需一句 :BundleUpdate。现在你已经完全从粗活累活中解放了出来,从此注意力只需放在挑选自己喜欢的插件上,还有比这更美好的么?下面介绍的所有的插件都以它来管理。 配色方案 你是否觉得用了许多年的白底黑字有些刺眼,又或者你是否厌倦了那单调枯燥?如果是,那好,VIM提供了成百上千的配色方案,终有一款适合你。 在所有的配色当中,最受欢迎的是这款Solarized: 在Github上它有4,930个Star,仅靠一个配色方案就得到如此多的Star,可见它有多么的受欢迎。它有两种完全相反的颜色,一暗一亮,作者非常具有创意将它们设计成一个阴阳八卦,赏心悦目。下面是采用这种配色的VIM截图: Solarized配色还有一个使它能够成为最受欢迎的配色方案的理由,除了VIM之外,它还提供了很多其它软件的配色方案,包括:Emacs, Visual Studio, Xcode, NetBeans, Putty,各种终端等等,应该是除了默认的黑白配色之外用途最为广泛的一种了。目前我采用的就是这种配色方案的dark background,它的对比度非常适合长期对着编辑器的程序员们。 还有一种很受欢迎的配色方案:Molokai,它是Mac上TextMate编辑器的一种经典配色,也非常适合程序员: 导航与搜索 NERDTree - file navigation 代码资源管理器现在已经成为了各种各样IDE的标配,这可以大大提高管理源代码的效率。这样的功能VIM自然不能少,NERD Tree提供了非常丰富的功能,不仅可以以VIM的方式用键盘来操作目录树,同时也可以像Windows资源管理器一样用鼠标来操作。 --help: 可以将打开目录树的功能绑定到你所喜欢的快捷键上,比如:map <leader>e :NERDTreeToggle<CR> CtrlP - fast file finder 如果说上面介绍的NERD Tree极大的方便了源代码的管理方式,那CtrlP可以称的上是革命性的,杀手级的VIM查找文件插件。它以简单符合直觉的输入方式,极快的响应速度,精确的准备度,带你在项目中自由穿越。它可以模糊查询定位,包括工程下的所有文件,已经打开的buffer,buffer中的tag以及最近访问的文件。在这之前,我用的是lookupfiles,因为依赖了其它的插件和应用程序,这个上古时代的插件逐渐被抛弃了。自从有了它,NERD Tree也常常被我束之高阁。 据说它模仿了Sublime的名字和功能,我没用过Sublime,但是听说CtrlP这个功能是Sublime最性感的功能之一。可以去它的官网看看。 --help: 这个插件另一个令人称赞的一点在于无比简单直观的使用方式,正如其名:Ctrl+P,然后享受它带来的快感吧。 Taglist - source code browser 想必使用过Visual Studio和Source Insight的人都非常喜爱这样一个功能:左边有一个Symbol窗口,它列出了当前文件中的宏、全局变量、函数、类等信息,鼠标点击时就会跳到相应的源代码所在的位置,非常便捷。Taglist就是实现这个功能的插件。可以说symbol窗口是程序员不可缺少的功能,当年有很多人热衷于借助taglist、ctags和cscope,将VIM打造成一个非常强大的Linux下的IDE,所以一直以来,taglist在VIM官方网站的scripts排列榜中一直高居榜首,成为VIM使用者的必备插件。 --help: 最常见的做法也是将它绑定到一个快捷键上,比如:map <silent> <F9> :TlistToggle<CR> Tagbar - tag generation and navigation 看起来Tagbar和上面介绍的Taglist很相似,它们都是展示当前文件Symbol的插件,但是两者有一定的区别,大家可以从上图的对比中得知,两者的关注点不同。总的来说Tagbar对面向对象的支持更好,它会自动根据文件修改的时间来重新排列Symbol的列表。它们以不同的纬度展示了当前文件的Symbol。 --help: 同Taglist一样,可以这样绑定它的快捷键,nmap <silent> <F4> :TagbarToggle<CR> Tasklist - eclipse task list 这是一个非常有用的插件,它能够标记文件中的FIXME、TODO等信息,并将它们存放到一个任务列表当中,后面随时可以通过Tasklist跳转到这些标记的地方再来修改这些代码,是一个十分方便实用的Todo list工具。 --help: 通常只需添加一个映射:map <leader>td <Plug>TaskList 自动补全 YouCompleteMe - visual assist for vim 这是迄今为止,我认为VIM历史上最好的插件,没有之一。为什么这么说?因为作为一个程序员,这个功能必不可少,而它是迄今为止完成的最好的。从名字可以推断出,它的作用是代码补全。不管是在Source Insight,还是安装了Visual Assist的Visual Studio中,代码补全功能可以极大的提高生产力,增加编码的乐趣。大学第一次遇到Visual Assist时带给我的震撼至今记忆犹新,那感觉就似百兽之王有了翅膀,如虎添翼,从此只要安装有Visual Studio的地方我第一时间就会安装Visual Assist。 而作为编辑器的VIM,一直以来都没有一个能够达到Visual Assist哪怕一成功力的插件,不管是自带的补全,omnicppcomplete,neocompletecache,完全和Visual Assist不在一个数量级上。Visual Assist借助于Visual Studio,它的补全是语义层面的,它完全能够理解程序语言,而VIM的这些插件仅仅是基于文本匹配,虽然最近的neocompletecache已经好了很多,但准确率非常低。所以在写代码时,即使VIM用得再顺手,绝大部分情况下我还是倾向于Visual Studio + Visual Assist。 但是YouCompleteMe的出现彻底的改变了这一现状,它对代码的补全完全终于也达到了编译器级别,绝不弱于Visual Assist,遇到它是我使用VIM之后最兴奋的一件事。为什么一个编辑器的插件可以做到如此的神奇,原因就在于它基于LLVM/clang,一个Apple公司为了代替GNU/GCC而支持的编译器,正因为YouCompleteMe有了编译器的支持,而不再像以往的插件一样基于文本来进行匹配,所以准确率才如此之高。其次,由于它是C/S架构,会在本机创建一个服务器端,利用clang来解析代码,然后将结果返回给客户端,所以也就解决了VIM是单线程而造成的各种补全插件速度奇慢的诟病,在使用时,几乎感觉不到任何的延时,体验达到了Visual Assist的级别。 YouCompleteMe也是所有的插件当中安装最为复杂的一个,这是因为需要用clang来编译相应的库。因为clang在Linux和Mac平台上支持的非常好,所以在这两个平台上安装相对简单。但是clang并没有官方支持Windows,所以YouCompleteMe插件也没有官方支持Windows。可这么好的东西,活跃在Windows上聪明的Vimer们怎么可能容忍这种事情呢,有人就提供了Windows Installation Guide,已经编译好了各种版本的YouCompleteMe插件,可以参考这个Guide来安装。我并没有采用它,而是参考了这里,自己编译了YouCompleteMe,其实也不难,一步一步按照介绍的步骤,相信你也可以。 YouCompleteMe除了补全以外,还有一个非常重要的作用:代码跳转,同样可以达到编译器级别的准确度,媲美Visual Assist与Source Insight。 有了YouCompleteMe之后,是时候抛弃昂贵的Visual Assist与Source Insight了。赶快安装尝试吧:-) --help: 只要设置好项目的.ycm_extra_conf.py,自动补全功能就可以完美的使用了。通常一个全局的.ycm_extra_conf.py足矣。代码跳转可以绑定一个快捷键:nnoremap <leader>jd :YcmCompleter GoToDefinitionElseDeclaration<CR>,很好理解,先跳到定义,如果没找到,则跳到声明处。 UltiSnips - ultimate snippets 这是什么?相信大家经常在写代码时需要在文件开头加一个版权声明之类的注释,又或者在头文件中要需要:#ifndef... #def... #endif这样的宏,亦或者写一个for、switch等很固定的代码片段,这是一个非常机械的重复过程,但又十分频繁。我十分厌倦这种重复,为什么不能有一种快速输入这种代码片段的方法呢?于是,各种snippets插件出现了,而它们之中,UltiSnips是最好的一个。比如上面的一长串#ifndef... #def... #endif,你只需要输入ifn<TAB>,怎么样,方便吧。更为重要的一点是它支持扩展,你可以随心所欲的编辑你自己的snippets。 现在它可以和上面介绍的YouCompleteMe插件一块使用,比如在敲完ifn时,YouCompleteMe会将这个snippet也放在下拉框中让你选择,这样你就不用去记何时按<TAB>来展开snippets,YouCompleteMe已经帮你完成。 去它的网站看看,有几个视频,绝对亮瞎你的双眼(需要翻墙)。 --help: 它和YouCompleteMe一块使用时会有一定的冲突,因为两者都默认绑定了<TAB>键,可以参考各自的help文档,将其中一个绑定到其它的快捷键,或者借助其它的插件让它们兼容。 Zen Coding - hi-speed coding for html/css 比一般的C/C++/Java等更多重复劳动的语言估计要算HTML/CSS这类前端语言了吧,为此前端大牛发明了Zen Coding,去这里(需翻墙)看看演示视频,相当令人震撼。如果是写前端的话,强烈推荐此插件。 --help: 可以去这里参考前端工程师们写的中文教程1,2 语法 Syntastic - integrated syntax checking 这是一个非常有用的插件,它能够实时的进行语法和编码风格的检查,利用它几乎可以做到编码完成后无编译错误。并且它还集成了静态检查工具:lint,可以让你的代码更加完美。更强大的它支持近百种编程语言,像是一个集大成的实时编译器。出现错误之后,可以非常方便的跳转到出错处。强烈推荐。 --help: 这是一个后台运行的插件,不需要手动的任何命令来激活它。 Python-mode - Python in VIM 如果你需要写Python,那么Python-mode是你一定不能错过的插件,靠它就可以把你的VIM打造成一个强大的Python IDE,因为它可以做到一个现代IDE能做的一切: 查询Python文档 语法及代码风格检查 运行调试 代码重构 …… 所以,有了它,你就等于有了一个现代的Python IDE,各位Pythoner们,还等什么呢? --help: 默认情况下该插件已经绑定了几个快捷键: K -> 跳到Python doc处 <leader>r -> 运行当前代码 <leader>b -> 增加/删除断点 其它 Tabularize - align everything 这个插件的作用是用于按等号、冒号、表格等来对齐文本,参考下面这个初始化变量的例子: int var1 = 10; float var2 = 10.0; char *var_ptr = "hello"; 运行Tabularize /=可得: int var1 = 10; float var2 = 10.0; char *var_ptr = "hello"; 另一个常见的用法是格式化文件头: file: main.cpp author: feihu date: 2013-12-17 description: this is the introduction to vim license: TODO: 运行Tabularize /:/r0可得: file : main.cpp author : feihu date : 2013-12-17 description : this is the introduction to vim license : TODO : 另一种对齐方式,运行Tabularize /:/r1c1l0: file : main.cpp author : feihu date : 2013-12-17 description : this is the introduction to vim license : TODO : 对于写代码的人来说,还是非常有用的。因为没有找到对应的图,所以这里就用另外一个插件的动画来代替了,Tabular的功能比它更为强大。 --help: 通常会绑定这样一些快捷键: nmap <Leader>a& :Tabularize /&<CR> vmap <Leader>a& :Tabularize /&<CR> nmap <Leader>a= :Tabularize /=<CR> vmap <Leader>a= :Tabularize /=<CR> nmap <Leader>a: :Tabularize /:<CR> vmap <Leader>a: :Tabularize /:<CR> nmap <Leader>a:: :Tabularize /:\zs<CR> vmap <Leader>a:: :Tabularize /:\zs<CR> nmap <Leader>a, :Tabularize /,<CR> vmap <Leader>a, :Tabularize /,<CR> nmap <Leader>a,, :Tabularize /,\zs<CR> vmap <Leader>a,, :Tabularize /,\zs<CR> nmap <Leader>a<Bar> :Tabularize /<Bar><CR> vmap <Leader>a<Bar> :Tabularize /<Bar><CR> Easymotion - jump anywhere VIM本身的移动方式已经是极其高效快速,它在编辑器的世界中独树一帜,算是一个极大的创新。而如果说它的移动方式是一个创新的话,那么Easy Motion的移动方式就是一个划时代的革命。利用VIM的#w、#b、:#等操作,移动到一个位置就像是大炮瞄准一个目标,它可以精确到一个大致的范围内。而Easy Motion可以比作是精确制导,它可以准备无误的定位到一个字母上。 这种移动方式我曾在Firefox和Chrome的VIM插件中看到过,跳转到一个超链时就采用了同样的方式,但是由于浏览网页的特殊性与随意性,当时我没有适应。在编辑的时候就不一样了,编辑更加专注,更带有目的性,所以它能够极大的提高移动速度。享受这种光标指间跳跃,指随意动,移动如飞的感觉:-P --help: 插件默认的快捷键是:<leader><leader>w,效果如上图所示。 NERDCommenter - comment++ 又是一个写代码必备的插件,用于快速,批量注释与反注释。它适用于任何你能想到的语言,会根据不同的语言选择不同的注释方式,方便快捷。 --help: 十分简单的用法,默认配置情况下选择好要注释的行后,运行<leader>cc注释,<leader>cu反注释,也可以都调用<leader>c<SPACE>,它会根据是否有注释而选择来注释还是取消注释。 Surround - managing all the “’[{}]’” etc 在写代码时经常会遇到配对的符号,比如{}[]()''""<>等,尤其是标记类语言,比如html, xml,它们完全依赖这种语法。现代的各种编辑器一般都可以在输入一半符号的时候帮你自动补全另外一半。可有的时候你想修改、删除或者是增加一个块的配对符号时,它们就无能为力了。 Surround就是一个专门用来处理这种配对符号的插件,它可以非常高效快速的修改、删除及增加一个配对符号。如果你经常和这些配对符号打交道,比如你是一个前端工程师,那么请一定不要错过这样一个神级插件。 --help::部分常用快捷键如下: Normal mode ds - delete a surrounding cs - change a surrounding ys - add a surrounding yS - add a surrounding and place the surrounded text on a new line + indent it yss - add a surrounding to the whole line ySs - add a surrounding to the whole line, place it on a new line + indent it ySS - same as ySs Visual mode s - in visual mode, add a surrounding S - in visual mode, add a surrounding but place text on new line + indent it Insert mode <CTRL-s> - in insert mode, add a surrounding <CTRL-s><CTRL-s> - in insert mode, add a new line + surrounding + indent <CTRL-g>s - same as <CTRL-s> <CTRL-g>S - same as <CTRL-s><CTRL-s> Gundo - time machine 现代编辑器都提供了多次的撤消和重做功能,这样你就可以很放心的修改文档或者恢复文档。可是假如你操作了5次,然后撤消2次,再重新编辑后,你肯定是无法回到最开始的3次编辑了,因为在你复杂的操作后,编辑器维护的Undo Tree实际上出现了分支,而一般的CTRL+Z和CTRL+R无法实现这么复杂的操作。 这时VIM的优势又体现了出来,它不仅提供无限撤消,VIM 7.3之后还有永久撤消功能,即使文件关闭后再次打开,之前的修改仍然可以撤消。而Gundo提供了一个树状图形的撤消列表,下方还有每次修改的差异对比,分支一目了然,相当于一个面向撤消与编辑操作的版本控制工具。有了它,你的文件编辑就像是有了一台时光机,可以随心所欲的回到任何时间,乘着你的时光机,放心大胆的去穿梭时空吧:-P --help: 通常会将这句加入_vimrc:nnoremap <Leader>u :GundoToggle<CR> Sessionman - session manager 这是VIM的Session Manager,作用很简单,管理VIM的会话,可以让你在重新打开VIM之后立刻进行之前的编辑状态,就像Windows的休眠一样,相信它一定是你工作的好伴侣。 --help: 我的配置如下: set sessionoptions=blank,buffers,curdir,folds,tabpages,winsize nmap <leader>sl :SessionList<CR> nmap <leader>ss :SessionSave<CR> nmap <leader>sc :SessionClose<CR> Powerline - ultimate statusline utility 增强型的状态栏插件,可以以各种漂亮的颜色展示状态栏,显示文件编码,类型,光标位置,甚至可以显示版本控制信息。不仅功能强大,写着代码时看着下面赏心悦目的状态状,心情也因此大好。像我一样的外观控一定无法抗拒它:-) --help: 简单实用,无需多余的配置。 终极配置: spf13 至此,我经常用到的所有插件都介绍完了,如果你也都安装尝试一下的话,相信很容易就配置出来符合你个人习惯的强大的IDE。也许有人会想,这么多的主题、个性化设置、插件,配置太麻烦,有没有已经配置好的,可以直接拿来使用呢?其实我当时也有一样的想法,在折腾了很久之后,发现_vimrc已经非常庞大且混乱,亟需整理。再后来就发现了它,spf13: 它是Steve Francia’s Vim Distribution,但是组织的非常整洁,容易扩展,并且跨平台,易于安装维护。在看到的所有_vimrc中,这是写的最漂亮的一个。只需要一个简单的脚本就可以安装,这里面利用了方便的Vundle集成了绝大部分前面介绍的插件,并且还有大量其它的插件,具体可以看它的.vimrc.bundles。 因为它完美的结构组织,你完全可以在不修改它任何文件的基础上,对应增加几个自己的~/.vimrc.local,~/.vimrc.bundles.local,~/.vimrc.before.local文件来增加自己的个性化配置,或者增加删除插件,可扩展性极强。在我的_vimrc乱成一团的情况,果断fork并安装了这个Distribution,增加了自己的一些配置,最终形成了现在的VIM。如果你也不愿折腾配置,那么完全可以直接安装它,省事方便的同时还可以学习一下它的组织结构,一举两得。 与其它软件集成 因为VIM的操作方式广泛为人们所逐渐接受,尤其是经常工作在Linux下的人们,所以它越来越多的被集成到其它一些常用的工具上,我用过的就包括: Visual Studio 本身Windows下的gVim安装包在安装时会提供一个集成到Visual Studio中的插件VsVim,可以选择安装,但它是另开一个VIM的窗口来编辑当前的文件,我并不习惯这种方式,所以又找到了ViEmu,它完美的将VIM的操作方式集成到了Visual Studio中,让你根本感觉不到这是在使用Visual Studio。更加强大的是,它可以完美的和Visual Assist集成: 在遇到YouCompleteMe之前,这就是我所采用的编程环境。但这是一个商业版的插件,只有30天的试用期,如果你真的喜欢它的,完全可以买下它,绝对物超所值。更为强大的是它还支持Xcode、Word、Outlook、SQL Server,这一定是一个极端的Vimer的项目:-),来看看它的动画: Source Insight VIM也可以集成到Source Insight中,不过我没有去找相应的插件,只找一种和前面介绍的VsVim一样的方法: 在Source Insight菜单中,Options-Custom Commands Run: “C:\Program Files\Vim\vim74\gvim.exe” –remote-silent +%l %f Dir: %d Add之后再Options-Key Assignments,将它绑定到一个快捷键中,比如F11 这样编辑一个文件时,如果你想打开VIM时,直接按F11,它就会跳到当前行,编辑完之后关闭VIM,又回到Source Insight中。这种方法我用过一段时间,后来由于很少用Source Insight写代码,也逐渐淡忘了。 Firefox/Chrome 在狂热于VIM的年代,我曾想把一切操作都变成VIM的方式,包括上网。所以就找到了Vimperator,但终究由于上网是一种更加随性、无目的的行为,拿着鼠标随便点点完全可以了,所以也就放弃它,回归到正常的操作方式下,有兴趣的可以把玩一下,很有意思,之前谈到的Easy Motion我就在这里见识过。Chrome下也有相应的插件。 一些资源 最后附上一些有趣有用的资源: 一篇非常好的为什么使用VIM的文章,请看这里 为什么VIM使用HJKL作为方向键?请看这里 为什么VIM和EMACS被称为最好的编辑器?这看这里 VIM作者的演讲:《高效编辑的7个习惯》,视频请点这里 写在最后 网上可能有很多人像我之前一样,过于关注工具本身,而忽略了一个非常重要的问题:工具之所以称为工具,仅仅在于它是被人们拿来使用,只要顺手就好,用它来做的事情才是关键。对于我们开发人员来说,专业知识永远比工具更为重要。自打VIM出生以来,就有几个亘古不变的话题: VIM vs Emasc VIM vs 其它编辑器 VIM vs IDE 争论从来没有平息过,从远古时期的大牛们,到刚刚踏入VIM阵营的我们,也从来没有一个结论。也许很多人争吵已经不再是单单的编辑器之争,而是出于维护心目中最好的工作方式,甚至哲学之争。但对于大部分人来说,只要你的工具足够称手,那么多写几行代码,多看些书,远比参与这些无休止的争吵强得多。但如果你更深一步,开发出更好的编辑器,或者插件,那又另当别论了。 这篇教程至此也将告一段落,说是教程,本文却并没有详细的介绍如何入门,反而回忆了一大段个人学习VIM的经历,然后介绍了常用的优秀插件。也许看完本文,你并不一定能够学会VIM,但是它提供了很多比本文更有价值去学习的资源,给了你一个整体的认识,让你看到VIM可以强大到什么程度,避免走很多弯路。看完本文之后,你能够知道如何入门,如何去选插件,我想,对于本文来说,这就够了。 (全文完) feihu 2014.01.07 于 Shenzhen

2014/1/7
articleCard.readMore