移动开发十年

内容简介

2017,顶着刘海的 iPhone X 带着“史上升级变动最大”的 iOS 11,依然碎片化严重的 Android 带着“更快、更强大、更安全” 的8.0来到我们面前。回首过去十年,从诺记的 Symbian、摩托罗拉的 Linux、苹果的 iOS、微软的 Windows Phone、三星的 Tizen 到 Google 的 Android 等,移动操作系统也曾百花齐放,但经过十年厮杀各自蚕食,格局已相当明朗,只剩下了 iOS 和 Android 两大巨头。

本期专题,我们特别组织了数十位开发者,一方面,是十年改变与回顾,敞开心扉地聊聊自己曾经历的或苦或甜的移动岁月;另一方面,从正在深究的技术以及应用案例等着手,将他们的经验分享给大家,希望能够帮助所有正在进行移动应用开发的朋友们,走出属于自己的更好的开发之路。

本书内容

十年一顾,iOS 与 Android 这样改变了我们

我从之前对智能设备一无所知的小白,到体验过无数智能设备的过来人;从只知 Hello World 的学生到解决无数 Android 开发问题的工程师;从 naive 到 sometimes naive。从一开始因为对 Android 基础知识的不甚理解,做了很多愚蠢的实现,踩了很多坑;到后面入了 Android 相机这一大坑,兼容性问题导致始终无法达到想要的最佳体验,仍然没有看到尽头……

当前,我正在进行图像、视频相关应用的开发,不仅仅关注普通应用层的开发技能,对图像视频领域的内容也有了更多的关注。最近从零学习移动端的 OpenGL ES 相关知识,基于相机模块的动效、滤镜有了更多的理解。同时,也在关心并学习人工智能、机器学习相关的知识,希望能够跟上节奏,掌握一些基础知识,并尝试在项目上进行落地实践。

至于将要到哪里去,我也不知道,同样有很多困惑,不知道坚持的是否要继续坚持,也不知道该放弃的是否早该放弃。2011年6月毕业找工作之前,目标很明确,我要加入移动浪潮,从事 Android 应用开发。面对实习机会、面临 Offer 的选择时都很容易做出判断并为之兴奋。而作为应届生在加入 HTC 时,虽然薪水很低却很有冲劲,为自己在最好的 Android 手机厂商工作感到高兴。但是在快满两年时萌生去意,一心只想去一线互联网公司,很幸运在2013年加入鹅厂,头半年感觉写了超过前两年的代码,头两年又感觉不知疲倦地在提升自己的编码技能,写了很多代码,也写了很多 Bug,修复了很多 Bug,写了很多技术文章,做了很多分享,认识了很多圈内有趣有才华并超努力的小伙伴。最近两年相比之下低调了许多,有故避锋芒的原因,也有自己遇到瓶颈后调整心态,适应团队的需要,不再只关注自己的成长,多了对产品、团队的正向积极思考。

有人说:人生不可能一直是一条上升的曲线,会有高潮也会有低谷。在有些阶段很容易直接看到显著的成绩,但是同样在很多看似非直接增长的阶段,重复训练,非抱怨坚持,专注团队贡献,收获的则是更加成熟的心态。如今已是我在鹅厂的第五个年头了,吐露自己的真实工作感想,勉励自己继续前行,也希望对同行有所帮助!

从业至今,我从一个职业程序员转变为一个独立开发者;从高效率解决命题的程序员思维,转变为创造性满足需求的开发者思维;从一个只对技术感兴趣的极客,转变为探索世界发展规律的思考者。

在移动行业摸爬滚打多年,犯过的最大一个错误就是 —— Mobile First。“移动”本质上只是一种形态,一条路径,一个解决方案,而不是趋势的内在本质。如果被 Mobile First 绑架了思维,则很可能在快速变化的发展中迷失方向。当我彻底摆脱了这一思维束缚后,才认识到过去十年的“Mobile”本质上是在充分发挥手眼协调的交互形态下,对信息接收和响应效率的一个优化实践。从这个认知出发,未来我们还可以进一步调动和挖掘更多交互形态对上述效率的补充和再平衡。

当前,我正在重新思考信息社会的基本生产关系,试图从深层次理解“付费”、“盗版”和“共享”交织下信息时代的价值根基。技术上,关注和研究去中心化和社会工程学在互联网环境的实践。

也希望能够做一个跟随自己内心疑问的思考者,把积累多年的开发技能和运作经验运用于实践自己的思考。同时,想分享给其他立志成为独立开发者的同学,除了掌握人机沟通的计算机语言之外,建议选修经济学和心理学。前者让你理解驱动这个世界的运行规律,后者让你通晓左右这个世界的复杂人心,掌握它们,你就拥有了守护麻瓜世界的魔法。

我毕业于2012年,也正是在这个时间前后,Android 手机井喷式发展,国内外各大厂商都投入了大量的资源来研发手机。在国内,Android 市场需求越来越大,这也导致了各大公司对程序员的需求越来越大。在那个时候,Android 程序员比较稀少,物以稀为贵,薪资也水涨船高。

在2012年下半年,我正式成为了一名 Android 程序员,接触 Android 的一刹那,我觉得喜欢它。可能是由于我比较喜欢做有界面的东西,而 Android 开发刚好契合了这一点。我花了一个月时间入门,然后就开始在公司里写代码了。

后来,我来到了北京,开始了自己的 Android 学习的旅程,我疯狂地学习、在 CSDN 写博客,短时间内,我的 Android 开发水平有了一定提高。再后来,我做了一些开源,还写了一本书《Android 开发艺术探索》。

而在学习技术的过程中,大家都是从新手一步步来的,最开始我也犯了一些错误。虽然现在回想起来非常好笑,但是当时的确是不会啊!其中,有两个问题印象比较深刻,一个是 View 的滑动冲突,早期时学习资料比较匮乏,也找不到很好的学习途径,所以我从网上下了一个侧滑菜单的 Demo。但是这个 Demo 有问题,如果在内容区域放一个 ListView,就会有滑动冲突,Demo 的作者解决不了这个问题,我自己查了很多书和资料也解决不了,那个时候这个问题真的很难,后来经过仔细钻研才解决了这个问题。

另一件事情就比较搞笑了,我在刚接触 Android 时,认为 Runnable 等同于线程,所以 View.post(runnable) 这个 API,我就以为这个 Runnable 会跑在子线程中。后来我才知道其实这个 Runnable 是运行在主线程度的。

到如今,我已经做了5年的 Android,应用开发和架构等也都接触过了,现在的计划除了继续 Android 开发之外,还需要多了解其他东西,毕竟广度也是很重要的。

其实深度和广度从来都不是互斥的,一个比较好的状态是:精通1-2个领域,其他领域都懂一点。但在优先级上,深度比广度更重要,深度决定专业程度,而广度决定着你的技术视野。大家做技术,当深度达到一定程度后,就需要加深广度了。或者说你觉着自己广度还行,那可能就需要补补深度了。

十年前,我还在读高中,当时用的手机是波导,作为学生最常用的通讯手段是飞信+短信,至今我还备份着当时的短信记录。后来读了大学,有了第一部智能手机 E71。工作之后开始使用 Android 和 iPhone。至今还能记住 Symbian 以及后来的 Android 和 iOS 初见时给我的惊艳,同时也感谢这些划时代的操作系统,我现在才能有一份不错的工作。

回首自己的移动之路,印象最深的是实习时刚接触 C++,用 iterator 遍历 list 的时候没有判断end,那个时候 NDK 开发调试极其麻烦,基本全靠日志定位问题,导师帮我排查到凌晨3点找到原因后才回家。

记忆犹新主要有两点原因——有些 Bug 往往是很低级的错误导致,以及很感激我导师的耐心和 nice。

当前,我正在进行移动终端的跨平台开发,而下一步,希望自己能够继续深造现有技术,同时也不断地去了解前沿技术。尽管编程语言可能会慢慢不流行,现有技术未来可能也会过时,但不论世界怎么改变,不变的是编程思想,保值的是技术思维。

所以,接触一个技术领域一定要触及这个领域的本质,取其精华。在不断深挖现有技术的前提下,也要不断了解前沿技术,防止被时代抛弃。

我从小菜鸟变成了拿着保温杯的老菜鸟,从不认为自己是别人所说的大神。求知若渴,大智若愚,始终对技术怀有敬畏的心态。但进入行业至今,忽略了很多技术之外的东西。有时间做开源项目,却没有多给家里打几个电话;有时间看书,却没时间和朋友多聚一聚;有时间写博客,却没时间和同事锻炼身体。

最近的我,正在 Android 技术边界上补充自己,比如 Groovy、Python、Java 语言实现原理等。而关注比较多的,还有 AR 应用开发相关的技术,比如 OpenGL、Unity 等等,因为我相信基于 AR 图像处理技术未来将会越来越普遍。

除了技术之外,还在考虑在其他方面提高自己的能力,比如演讲能力、沟通及管理能力等,作为一个程序员这也是必须要趟的坑。

我从不盲目地追逐最新的技术,而是关注计算平台的发展,比如,PC 到现在的移动开发平台再到之后的 AR 平台。

其实在刚进入这个行业一两年时,是真正困惑技术人员的阶段,到底要不要坚持现有的技术?要不要学习新技术?在我看来 BAT 对各个评级的要求是非常具有参考性的,也就是在哪个阶段我们应该达到什么样的目标和能力。比如,阿里 P7 就要求在某个领域要有专家级水平、五年工作经验及跨团队的协作能力,P8 就要求管理过20人以上的团队,及引领行业发展的技术能力,也就是说,如果没有在一个领域有专家级水平和跨团队的管理经验是不可能到 P8 评级。

所以,我建议,做一项技术最好就要做到专家水平,对比 P7。然后再考虑往其他相近技术领域扩展,也方便提升跨领域团队的管理能力。

差不多十年前,Android 开始公测,出于对 Google 的喜爱,我在第一时间开始学习,并参与了随后 Google 举办的 Android 开发全球挑战赛(ADC),那时,离第一台搭载 Android 系统的手机 G1 发布还有一年多。

虽然比赛没拿到奖金,但这份经历让我机缘巧合地走进了移动互联网的大门,顺理成章地开始做移动开发、研究移动技术、写技术书籍、做 App Store、做各种各样的移动 App。就像站在了一个新世界的门口,感受着移动时代一步步迎面而来。感谢 iOS 开启了移动时代的大门,感谢 Android 让这个时代极速成长,亲历这一切,是个有趣的经历。

十年前,大家对移动互联网的面貌充满疑惑,还在激烈地争论 Android 是否可以战胜 Symbian,一个 Java 做成的系统怎么可能在手机上跑流畅;开发中,需要花大量的时间来优化网络性能,因为大部分手机都还停留在 2G 时代;设计上,需要研究各种外设下的移动应用交互,因为除了触屏,硬键盘、轨迹球都还是主流;手机上每一个字节的内存都极为珍稀,即便是那时最好的手机,内存也只能论 M 来计数。

后来在豌豆荚,我们花很多时间去研究如何让 PC 可以更稳定地和手机连接,因为 PC 还是用户主要上网、获取移动应用的场所;然后,我们开始在手机上做下载库,为了最大化地节约流量,做预取、增量包;我们还会自己做网络库、推送服务、插件方案,花了很多精力做这些现在看上去是造轮子的事情,当时,还都只能靠自己。

这些技术积累,在当年都值得津津乐道,而时至今日,却是鲜有用武之地。很多技术完全不需要了,很多实现方式可以被更好的移动系统、开源库所取代。如果说,在移动互联网时代有什么最印象深刻,应该是这个时代的软硬件技术的进步都实在太快,永远别想躺在温床上吃老本。

如今,十年已矣,我依然还在从事移动互联网开发,创办了一家新的公司叫作“轻芒”,来探寻高品质内容在移动互联网时代如何更有效地分发。并且,选择了微信小程序作为发布产品的第一选择,而不再是 iOS 或 Android。十岁的 iOS 和 Android 虽然越来越强大,但奔跑的节奏已不像年轻时的模样,新的产品机会、新的技术变革、新的可能性,也许,会出现在新的平台上。

十年中,我在 Windows、Symbian、Android、iOS 都做过开发,可以想见,在将来,还会不断有新的平台出现,等待我们去探寻。

从技术趋势来看,新平台开发的硬技能门槛越来越低,以前你不懂寄存器,不懂文件系统,没有扎实的数学功底,都没法从事开发。而今,编程语言越来越接近自然语言,三五行代码就能落地一个漂亮的交互,一个小程序就能跑个复杂的机器学习。对于开发者而言,更有价值的地方源自于软技能的不断积累,乐于学习和了解新的技术趋势,可以持续地去新的平台上构建产品,研究新技术的各种可能性;善于去了解产品的全貌,以便设计更符合未来需求变更的技术框架;能够把代码表述得清晰准确,让维护的成本不断减少,有更多的精力去尝试新的机会,诸如此类。

这个行业最有趣也最有挑战的事情,就是每天去面对新的事物,保持专注,保持好奇,也许是最好面对下一个移动时代的最佳态度。

十年时间是一个很长的跨度了。十年前我才大二,和老婆虽是同班,但也不过点头之交,而现如今都已经拖家带口了。十年前满大街都是诺基亚,我也完全没有想过自己以后会进入到移动开发的领域。而随后到来的移动和智能手机大潮,给这个世界带来的改变远远快于以前。能够进入引领这个时代的行业,可以说是十分幸运的事情。这十年来的经历,对我来说,不管是观察世界的眼界,还是对待人生的态度,都有了升华,也明白了很多担当。虽然我也只是一个普普通通的开发者,但是我始终相信通过自己的努力,可以让这个世界变得更好,哪怕只有那么一点点。

而说到刻骨铭心的坑,在移动应用的世界里,其实很难犯什么大错,顶多可能就是程序崩溃需要强制更新,不会存在什么删库跑路的情况。不过要说起来的话,那就是早期有些时候会有些自负。一方面是年轻,恃才气傲,觉得自己已经很了不起;另一方面是怎么看周围的同伴或同事都觉得不够给力。其实明明自己做的事情并没什么技术含量,偶然的成功大多也是团队努力的成果,自己的力量真的十分有限。而好在最近在审视过去的时候有主动意识到这点,这可能会对我之后的人生产生很大影响。

现在的我,正埋头专心踏实工作,另外也在业余时间纯粹由于兴趣在看一些机器学习的东西。专业之外就是在带娃这场漫长的战役中打好辅助位的角色,负责“买鸡买眼”。移动开发虽然余波犹在,但显然资本最狂热的时期已经过去。于个人而言希望继续专注技术,虽然不一定是移动领域,但可能还是会进行相关的工作。之前有一段时间很多朋友会问,除了 iOS 之外还需要学些什么?可以看出很多朋友会对此有忧虑,所以我想给出的答案是:还需要学会学习。没有什么是一成不变的,特别是技术方面,日新月异,用不了几年就会改天换地。但是持续一生不断地学习,以及坚持不懈的人生态度,将帮助你永远站在时代的前头。

在做移动开发之前,我是一个服务器端 Java 工程师。从来没有想过一台智能手机能够引起整个互联网行业产生巨大的变化。现在,移动互联网已经深入每一个人生活中的方方面面,我也成为了移动开发者中的一员。

回首过往,我曾经一度认为 HTML5 技术可以取代原生的开发方案。但是就像 Facebook 的 CEO Mark Zuckberg 说的那样,这是 Facebook 曾经犯下的最大错误。一个技术方案的成熟需要时间,或许未来 HTML5 可以统领移动开发领域,但是至少过去不是,最近几年也不是。

在移动互联网的影响下,教育也是一个被改变得翻天覆地的行业,我现在在猿辅导公司负责小猿搜题的产品技术,我们希望用技术改变中国的教育行业,让学生的学习效率更高,并且打破教育资源在地域上的不平衡。

未来无论多么美好,都是当下努力的结果。移动互联网经历了最好的一个十年,未来的下一个十年,它会更加成熟,好的机会同样会有,但是不会像刚开始的十年那样满地都是。于自己而言,我希望能够在教育这个方面做到足够好,打造一家成功的上市公司。

很荣幸在移动浪潮刚到来时就进入了 iOS 领域,移动开发改变了我对很多技术的看法。Apple 每年都会为了推动许多新的技术标准而在 App Store 审核上提出“霸道”的条款,但是回过头来看,确实推动了新技术标准的落地,为技术发展铺平了道路。Apple 时常在技术发展中扮演着“革新者”的角色,无论是全链路 HTTPS、IPv6 支持、64-bit 支持、ECN 扩展协议等,这也在改变着我对这些技术的理解。

另一方面,每年 WWDC 都会有新的技术出现,几乎每年都必须强制适配 App,包括 SDK 接口、Swift 语法等。这种看似“不平滑”的升级策略,也透露着 Apple 对技术发展大刀阔斧的革新气魄。同时也要求开发者及时更新自己的知识体系,也正因如此,强化了我学习新技术的意识。

回首我的移动开发之路,印象颇为深刻的,是有一次遇到一个问题,紧急修复后发版本,之后又在另外的地方出现了类似的错误。在上一次修复时,只是遇到问题处理问题,没有做到相似问题排查,也没有思考如果规避类似问题的长效机制。听说日本有的软件公司,如果修复 Bug,必须同时找出类似的错误才允许合并代码,这就是保证软件质量很有效的方式。

当前,我正在做些 iOS 领域的基础服务,希望能够为更多开发者提供更简单的方式,来提高开发效率,提升 App 的性能、质量,提供更多的最佳实践。

对于我们所有的应用开发者,技术不能只学皮毛,不能抱着“够用、会用就行”的态度。Little knowledge is dangerous!追求卓越,对技术细节做到刨根问底。

毕业之后一开始从事的是 Java 后台开发,适逢移动开发如日中天及公司业务需要,遂转入 iOS 开发。算起来已有5年,也可算一个老司机了。

iOS 开发最大的一个感受是宽度大于深度,得紧跟苹果的步伐,不断去吸收新的东西,研究每个 iOS 版本的变化。从 MRC 到 ARC,从 Autoresizing 到 Autolayout,从顶部状态栏放开到 iPhone X 的“刘海”,从指纹识别到面部验证,从摇一摇、语音识别到增强现实、机器学习,无一不要快速学习,迅速吸收,融入到具体的功能开发中去。

印象中踩过最大的坑莫过于 Block 回调中野指针的问题,估计这也是当前众多 iOS App 崩溃的主要原因之一。虽然这个问题对于一众老司机来说很容易跳过去,但是新手上路者能不跌进去的很少。

我在58同城进行开发已经三年多了,主要负责主 App 开发。期间经历了 iOS 开发主流框架的演变,从最开始的 Hybrid 框架为主要模式到现在的纯 Native 开发的主要模式,并且最近两年融合了 React Native 的相关技术。在框架的演变过程中,一方面是技术的影响,另一方面是各业务线并行开发的需要。目前主要负责的内容一个是 React Native 在 iOS 端的接入及相关底层技术支持;另一方面致力于形成一个松散耦合的架构,以更好地支持业务线的并行研发。

移动开发的本质在于前端,是与用户接触得最近的开发,所以开发出用户体验最好的 App 一直是我的目标。但与此同时,随着硬件设备的沉浮与大前端开发的兴起,也倒推着我不断去接触大前端领域的各项技术,在技术的变革潮流中不掉队,不落伍。这也是我向同行一起共勉的一个目标。

我是2008年开始进入移动开发行业的,到现在也差不多十年了。有幸见证并参与了移动行业的兴“衰”,让我有机会不断地学习和折腾各种前沿技术。但感触最大的是:在智能手机盛行以后出生的那一代人已经把手机变成生活的一部分,遇到想看的动物就说在手机上 Google 一下,看到任何屏幕都想点击,拖拉和缩放。

做开发要折腾各种新技术,每天都或多或少地掉进一些坑,但踩过的坑都很快地通过 Google 和 Stack Overflow 解决了(Stack Overflow oriented programming)。如果连官方文档都解决不了的问题也通过问题的拆解和分离等步骤慢慢迎刃而解。

目前,我主要在学习和应用 Kotlin,并把它应用到公司的 Android App 里,慢慢地用 Kotlin 来替换原有的 Java 代码。和三年前把原有的 iOS App 从 Objective-C 转换到 Swift 一样,也遇到不少坑,但过程很具挑战性。

谈到规划,我目前还是专注于移动开发,主要是 Swift 和 Kotlin 在原生 App 上的应用。我的兴趣点是如何通过设计来驱动开发,快速地开发出具有良好用户体验的 App,这也是我之前开发 IBAnimatable 开源项目的原因。同时也在学习和折腾各种技术,包括 React Native、VR、AR、AI、区块链等等,为下一个计算平台做准备。公司里的同事问我有什么经验可以分享,我一般都说“Get yourself ready, wait for opportunities”。假如工作上不做 AI,而我对这方面感兴趣,那就去 Coursera 把相关的课程学习一下,找相关的项目或工作机会,随时为下一波计算浪潮准备。当然,现在 AI 已经进入应用阶段,不是下一波了。

之前一直做 C++ 和 Windows 底层的开发工作,后来由于一次工作需要有了第一次接触 iOS 和 Objective-C,记得彼时还是 iOS 4+ 黑苹果的时代,一直到现在的 iOS 11,可以说实现了从最初的一名 Windows C++ 开发者到移动平台开发者的转变。

而进入移动应用开发行业,我先后开发过 iOS、Android、BB10、Windows Phone 项目,记得以前有一个大型 iOS 项目要求上层 Objective-C 编写、中间层使用 Objective-C++ 编写,底层 C++ 编写跨平台库。当时负责中间层和底层库的开发,国内 iOS 项目的资料还非常少,大部分都是英文的,于是,编写底层 time 库和线程库一点点地啃着英文。

现在的我,正在 iOS 框架架构、核心公共库、性能分析优化等方面前行着。而将来,一方面希望接触新鲜技术和思想,把它们变成有价值的代码真正跑起来,同时也希望可以多贡献分享出一些优秀的开源库。

58同城 iOS 客户端网络框架的演进之路

文/郭方清

伴随着业务场景需求的变化,58同城 App 在网络架构层面经历了从使用第三方开源网络框架到自主研发框架的不同阶段的不断改进。本文作者即从 iOS 开发角度具体分享了58同城移动客户端在网络框架层面的几次演变改进实践与经验总结。

成熟的互联网企业 App 往往离不开对网络数据交互的依赖,因此一个封装良好且健壮易用的网络框架不可或缺。本文从 iOS 开发角度来概说58同城移动客户端在网络框架层面的几次演变改进实践,希望能给大家带来些许具有参考价值的分享经验。

iOS 常用网络框架介绍

大部分 iOS 开发者知道的两个第三方开源网络框架 AFNetworking(后文简称 AFN)和 ASIHTTPRequest,都是基于 Apple iOS/OS X 底层网络接口的封装,友好的接口设计又不乏健壮实用,因此备受 iOS 开发者喜爱,同时被诸多大型 App 直接集成使用。可惜后者在2012年便停止更新了,而 AFN 多年来却一直保持不断地改进更新,以致一些近两年初入 iOS 行当的开发者就只知 AFN了。AFN 确实是一个值得学习的成熟网络框架,这里不做过多探讨,大致介绍其工作在系统框架的所属层次,以便于下文讲解我们的网络框架原理,如图1所示。

图1  AFN框架层次

图1 AFN 框架层次

AFN 在3.0版本之前都是基于 NSURLConnection 封装,3.0之后大刀阔斧般地修改,加入了对 NSURLSession 的支持,后文再来详说这个 Apple 在 iOS 7以后开放的新类。

为什么网络框架多次改版

曾经有人质疑道,真正稳定健壮的框架是无需经常变更的,为什么你们的网络框架会经历几次大改版?到底又是什么在驱动框架不断改进呢?依照我们的经历来看,主要总结为两个原因:

业务驱动——没有永远不变的程序,只有不断变更的需求;新技术驱动——成熟的新技术往往是可以解决问题的利器。本文就以时间为坐标来依次解析我们在每次改版中遇到了什么样的问题,又是出于怎样的考虑并着手解决的。

直接使用 AFN 阶段

我们曾经和绝大多数 App 一样,直接使用现成的开源框架 AFN。但随着业务不断扩展,单靠直接使用 AFN 的接口逻辑已明显不能满足我们的需求,同时对于后期的维护也会带来诸多不便。主要表现为:

  • 接口调用分散,不便于更新维护;
  • 网络依赖、优先级管理仍需再次封装;
  • 无法实时监控网络状况管理并发;
  • 不便于统一过滤网络层异常数据。

针对以上几点,下文具体阐述其中的缘由。我们的工程中类似这样的网络请求代码曾经随处可见(如图2所示)。

图2  网络请求代码

图2 网络请求代码

业务模块对于 AFHTTPRequestOperation 的依赖过于严重,在整个工程内全局检索一遍大致存在几百处。然而这种直接引用第三方库的方式在遇到其重大更新时便会带来灾难性的修改,显然 AFN 3.0 在去掉 AFHTTPRequestOperation 这个类以及旧接口时,相信许多 App 都深受其苦。我们在 AFN 3.0 版本更新之前就考虑到了此类问题并预先进行了调整,解决方案如图3所示。

图3  调整方案

图3 调整方案

二次封装 AFN 阶段

正如图3所示,二次封装 AFN 后各业务模块直接调用 WBNetworkManager 的接口,这就隔离了业务层与 AFN 的直接交互逻辑,这么做显而易见的好处是解决了上文中提到的第1个问题,即使 AFN 如何更新变化(甚至是切换为别的网络框架),业务层代码都不用做出任何调整,从而便于后期的维护。

促使我们进行二次封装 AFN 的主要原因在于业务需求的调整,比如需要监听当前设备的网络状况(2G/3G/4G/Wi-Fi)变化来实时控制当前网络请求的并发量、需要简单实现两个网络请求的优先级或依赖关系、能否在网络层过滤掉异常数据等,从而迫使网络框架做出调整来适应这些业务需求。因此进入了基于 AFN 做二次封装的阶段,设计 WBNetworkManager 并暴露适当的网络请求接口供业务层调用,内部实现以上通用业务需求。

基于如图4所示的对 AFN 二次封装后,我们做了以下几方面在网络框架的改进:

1) 网络操作优先级、依赖机制

由于 AFN 现成的网络操作 AFHTTPRequestOperation 是继承自系统 NSOperation 实现的,也就是说每个网络请求操作我们都可以把它添加到自己封装的 NSOperationQueue(操作队列)中来统一管理,从而实现了对每个网络请求操作的优先级、依赖的管理。这对于某些业务场景是十分有用的,比如我们的 App 在首页数据展示之前会同时并发请求十多个服务端接口,然而用户最关心的可能是首页的展示,所以必须控制首页数据请求为高优先级操作。

2) 网络质量监测机制

图4  网络框架设计

图4 网络框架设计

如图4中我们实现的网络状况实时监控模块,每当网络质量发生变化时来改变一些策略。比如 2G 环境下网络超时的设置就不应与 Wi-Fi 下一致,同样在这两种不同的网络环境下,整个 App 的网络请求并发量也不应相同,从而提升 App 的网络体验。

3) 防死锁与长驻机制

由于增加了网络请求的依赖机制,假设 ABC 三个模块可以同时发起网络请求但是 A 需要等待 B 有结果以后才能执行,又或者存在类似的顺序先后关系,就很容易因操作不当发生死锁。所以我们增加了守护线程来防止操作队列不空的情况下发生死锁,同时还能保证在网络调用者生命周期结束或驻留时间超过预设的限制时尽快释放网络资源(主动取消这些操作)。

自主研发框架阶段

经过以上讲述的阶段,似乎已经满足了大部分的业务场景,这也符合开篇讲到的业务驱动框架不断改进的说法,那什么又是技术驱动呢?Apple 在 WWDC 2015上特意讲到了 NSURLSession (iOS 7 开放的新类)在 iOS 9 及以后系统开始支持 HTTP/2。然而此时大部分 App 也都是兼容到 iOS 7 及以上系统,这就意味着 NSURLSession 将被广泛应用,AFN 也是从3.0版本开始切换到对 NSURLSession 的支持。

相信 AFN3.0 的更新给那些直接集成的 App 带来不少麻烦,比如弃用了 AFHTTPRequestOperation 类,从而不再支持直接做线程依赖,而使用全新的类及接口命名,因此使用新版 AFN 就无法继续沿用图4中的框架设计,与其总被 AFN 的改动牵着走,索性不如研发一套既适合自己业务场景又能对新类支持的框架,我们就开始进入了自主研发网络框架的阶段。回归到技术点上,NSURLSession 的出现到底有哪些优势呢:

  • iOS 9 及以后对 HTTP/2 的支持(Session 共享等特性);
  • 实现上传、下载以及断点续传更简易;
  • 支持后台工作任务。

其中最显著又实用的莫过于对以快著称的 HTTP/2 的支持,这里就不过多地阐述 HTTP/2 的原理及优势了,做过 SPDY 优化网络的开发者应该都懂得其中的美妙。优势都清楚了,我们又是怎样解决上面提到的 AFN3.0 中的不足呢?

先来说下 NSURLSession 怎样与 NSOperation 结合使用来实现并发、依赖控制的需求。如图5所示,封装可以共享 Session 的 SessionManager 管理类,利用继承自系统 NSOperation 的线程操作类来发起不同的 SessionTask(网络请求任务),并加入到操作队列中统一管理,从而就能控制相应的并发与依赖。由于使用共享的 Session,并发的网络数据返回到同一个 Delegate,通过 SessionTask 的唯一标识(taskIdentifier)分发至队列中相应 Operation 来处理数据解析及回调并完成出队。

图5  并发依赖控制实现

图5 并发依赖控制实现

这里不得不提的一点就是为什么要尽量地共享 Session。我们都知道每次发起一个新的 HTTP 请求需要经历 TCP 的3次握手才能开始接收数据,而共享Session便是能复用 TCP 的连接,从而节省了重复3次握手建立连接的时间。图6是我们在列表页请求相同的接口、相同的环境下得到的数据,单从数据上来看虽然有一些提升,但相比 HTTP/2 宣称的数倍还差挺远,当然这里最重要的因素取决于服务端是否完全支持 HTTP/2。

图6  相同接口、环境下得到的数据对比

图6 相同接口、环境下得到的数据对比

至此,我们的网络框架已完全脱离对 AFN 和其他第三方库的依赖。作为客户端独立的底层服务框架而存在,这既满足了网络方面的业务需求也便于后期的扩展与维护,从我们的经历来说突出的优势主要体现在以下几方面:

  • AFN 的剥离与 NSURLSession 切换,业务调用方毫无感知(网络公共接口未改变);
  • 客户端做 HTTPS 灰度测试,只需要简单的增加网络层 URL 筛选替换即可实现;
  • 网络层返回异常数据过滤,在数据返回反序列化的阶段利用 Runtime 的特性去映射到相应数据模型限定的数据类型并赋值,从而避免了类似 null 数据及其他非法类型数据。

总结

58同城移动端高峰时期 DAU 达千万级,每一个用户都是我们前行的动力,绝不会轻易放弃任何一个可以提升用户体验的技术细节,本文仅从技术角度来讲述我们的网络框架在不同阶段演变改进之路。技术框架其实并无绝对的好坏之分,站在不同的相对角度会有不同的结论,然而技术往往是服务于业务,所以说只有适合你自己业务场景的技术框架才算是最合理的框架。

58同城 iOS 客户端组件化演变历程

文/曾庆隆

架构的演进是为业务不断发展服务的,架构不能脱离业务,这是最基本的出发点。58同城 iOS 客户端随着业务量和用户量的持续增长,架构也是不断受到挑战,采用什么样的架构去适应这些变化,对技术人员来说也是一大考验。58 App的架构先后经历了纯 Native、引入 Hybrid 框架、底层服务组件化、业务线组件化,即整个 App 组件化的四个阶段。

第一版 App 架构

早在2010年58同城诞生第一版 iOS 客户端,按照传统的 MVC 模式去设计,纯 Native 页面,这时的功能较为简单,架构也是如此,从上至下分为 UI 展现、业务逻辑、数据访问三层,如图1所示。和同期其他公司一样,App 的出发点是为了快速抢占市场,采取“短平快”的方式开发。纯 Native 的 App 在早期业务量不是太大的情况下,能满足业务的需求。

图1  App早期架构

图1 App早期架构

第二版架构

Hybrid 框架需求

由于苹果审核周期较长,业务需求不断增大,有些业务如果用 Native 进行开发,工作量大投入人员较多,也不能动态更新,如 58 App 的大类、列表、详情页面。这种情况下,用 HTML5 是比较流行的解决方式,由此产生了第二版架构,如图2所示,在 UI 层添加了 HTML5 页面及 Hybrid 交互框架。当时 58 App 设计时用于加载 HTML5 的组件是 UIWebView,也只能使用这个(彼时还没有 WKWebView),但实现起来有几个问题是需要解决的:

图2  带 Hybrid 的架构

图2 带 Hybrid 的架构

  1. 怎么解决 Hybrid 中 Web 和 Native 交互问题,如用户点击一个类别,能调起 Native 的一些方法去执行相关页面跳转或写日志。

  2. 如何提高 HTML5 页面的加载速度,HTML5 页面加载时要下载一些 JavaScript、CSS 及图片资源,是比较耗时的。

设置缓存

为了方便描述,本文先介绍如何提高 HTML5 页面加载速度的问题。

对于一些访问比较频繁的页面,如大类列表详情,我们早期采用的都是 HTML5 页面。要加速这些页面的渲染,就要想办法提升资源的加载。那么如何实现呢?首先想到的是使用缓存,我们可以把这些页面的资源内置到 App 中随版本发布。

由于 UIWebView 在发请求的时候都会走 NSURLCache 的这个方法:

- (nullable NSCachedURLResponse*)cachedResponseForRequest:(NSURLRequest *)request;

我们可以从 NSURLCache 派生出子类 WB Hybrid Component,复写 cachedResponseForRequest:方法,在这之中加载 App 的内置资源,具体加载策略可见图3。

图3  缓存处理流程

图3 缓存处理流程

其中,H5ViewController为HTML5 载体页面,WBCacheHandler 为专门处理内置资源类,用于加载、查找、下载、保存内置资源。URL 的 query 中设置版本号参数 cachevers 作为资源缓存的标识,其值为数字类型,假设 cachev1,其与内置资源中的版本号如为 cachev2 进行对比,若 cachev2>= cachev1,表示内置资源中是最新数据,直接给请求返回数据;否则下载新的内置资源,同时根据 cachev1- cachev2 的差值进行判断,如设置一个临界值 x,若差值大于 x,则说明内置资源为旧,给请求返回 nil,否则返回内置数据,让请求先用缓存数据,下次启动时再用新数据。

内置数据采用的是一个 bundle 包,如图4所示,CacheResources.bundle 为内置包名,里面包含了一个索引文件和若干个内置数据文件,其中索引文件中每项 item 格式为 key、版本号和文件名。

图4   缓存包结构

图4 缓存包结构

想要使用自定义的 NSURLCache,必须在 App 启动时初始化 WB Hybrid Component,并进行设置,替换默认的 Cache,注意:这个设置必须在所有请求之前进行,否则设置失效,而是采用默认的 NSURLCache 实例,我们曾经踩过这个坑。

// URLCache初始化WBHybridComponent *hybridComp = [[WBHybridComponent alloc] initWithMemoryCapacity:MEM_CAPACITY diskCapacity:DISK_CAPACITY diskPatch:nil];[NSURLCache setSharedURLCache:hybridComp]

基于 AJAX 的 Hybrid 框架

对于前面所列的第一个问题,我们是要设计一个 Web/ Native 的 Hybrid 框架。交互主要包括两部分内容,一是 Native 调用 Web,这个比较简单,直接通过 UIWebView 的 stringByEvaluatingJavaScriptFromString:执行一段 JS 脚本,并返回执行结果,本文主要分享 Web 调 Native 的方法。

对于 Web 调 Native 交互的方式,我们采用异步 AJAX 进行,创建一个 XMLHttpRequest 对象,执行 send() 进行异步请求,Native 拦截。

xmlhttp.onreadystatechange = function() {    if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {      // 处理返回数据    }  };  xmlhttp.open("GET", "nativechannel://?paras=...”, true);  xmlhttp.send();

由于 XMLHttpRequest 的方式是进行页面局部刷新,并不能被 UIWebViewDelegate 代理的- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType 方法拦截到,设计到这里又出现了新问题,如何让 Native 能拦截到 AJAX 请求呢?

经过一番调研,我们找到了用于缓存的 NSURLCache,对于 UIWebView 中的所有请求(包括 AJAX 请求)都会走 NSURLCache。因此,我们决定采用复用缓存中的 WB Hybrid Component 拦截 AJAX 请求,具体 Web 调 Native 的交互设计如图5所示。

图5   Hybrid 框架处理流程图

图5 Hybrid 框架处理流程图

其中,H5ViewController 为 HTML5 的载体页,WBWebView 是 UIWebView 派生类。WBWebView 中通过 AJAX 发出的异步请求,在 WB Hybrid Component 中被拦截,再通过 WB Hybrid JSHandler 中的 dic 表找到对应的 WBActionAnalysis 对象,然后在 WBActionAnalysis 中分析异步请求传过来的协议,取出 action 字段,再根据 action 值找到 delegate 即 H5ViewController 中对应的方法。

AJAX 发出的请求我们约定为:

Nativechannel://?paras=<json协议>,WB Hybrid Component 在拦截时判断 URL 中是否为 Nativechannel 的协议头,如果是则为 Web 调起 Native 操作,需要进行后续 Native 处理;否则放过进行其他处理。<json协议>的简化格式如图6所示,这是二手车大类页点击二手车类目 Web 调 Native 时 AJAX 传过来的协议。

图6  Web调 Native传输协议

图6 Web 调 Native 传输协议

改进的 Hybrid 框架

前面我们设计的 Hybrid 框架,通过创建 XMLHttpRequest 对象发送 AJAX 请求的方式能达到 Web 调 Native 的目的,也可以满足业务上的需求,在一段内发挥了重要作用。但随着时间的推移,这个 Hybrid 框架暴露出了一些问题,如下所示。

  1. 我们发现 App 中存在大量的内存泄露,经查罪魁祸首竟是 UIWebView。调研发现 UIWebView 中执行 XMLHttpRequest 异步请求时会有内存泄露,网上也有人探讨过这个问题,参考博客:http://blog.techno-barje.fr//post/2010/10/04/UIWebView-secrets-part1-memory-leaks-on-xmlhttprequest/。
  2. Hybrid 交互方式与缓存都使用 NSURLCache 的派生类 WB Hybrid Component 执行拦截,其初衷也是用于缓存。我们的 Hybrid 框架将两者耦合在一起,这对于后期的开发和性能优化工作会带来不少隐患。
  3. 我们在 Hybrid 交互的时候维护了一个<URL, WBActionAnalysis>列表,需要消耗额外空间,同时也存在一个隐患——如果两个 HTML5 页面的 URL 相同,这时列表中只有一份,在回调时会存在问题,当然,其机率特别小。

对于这些问题,我们调研了一些解决方案,最终决定采用 iframe 方式,向 DOM 中添加一个空的 iframe,设置其 src 属性,实现代码如下所示。

//创建iFrame元素variFrame= document.createElement("iframe");//设置iFrame加载的页面链接iFrame.src= "nativechannel://?paras=<json协议>";//向dom tree中添加iFrame元素,以触发请求document.body.AppendChild(iFrame);//请求触发后,移除iFrameiFrame.parentNode.removeChild(iFrame);iFrame = null;</json协议>

由于 iframe 方式是整个页面刷新,所以能执行 UIWebViewDelegate 的回调方法- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType。我们可以直接在这个方法中拦截 Web 的调起,iframe 方式处理流程如图7所示。

图7  iframe的 Hybrid 交互方式

图7 iframe 的 Hybrid 交互方式

通过 iframe 的方式,我们 App 极大地简化了 Hybrid 框架的交互流程,同时也解决了内存泄露、与缓存功能耦合、消耗不必要的内存空间等问题。

第三个版本架构

随着业务的进行,一些新的技术需求来了,比如有些基础模块可以从 App 中独立出来进行多应用间的复用;需要为转转 App 提供一个日志 SDK;为违章查询等 App 提供登录的 Passport SDK;为其他 App 提供一个可定制化的分享组件等等。

App 拆分组件

这时我们迫切地需要在工程代码层面对原来的 App 进行拆分、组件化开发,如图8所示。

图8  第三版架构

图8 第三版架构

我们将 App 拆分成三层,从下至上依次是基础服务层、基础业务层、主业务层:

  1. 基础服务层里的组件是与业务无关的,供上层调用,每个组件为一个工程,如网络、数据库、日志等。这里面有些组件是整个公司的其他 App 也在使用,如乐高日志,我们对外提供一个 SDK,与文档一起放在代码服务器上供其他团队使用。并将 58 App 中用到的所有第三方库都集中起来存放到一个专门的工程中,也便于更新维护。
  2. 基础业务层里的组件是与业务相关的,供主业务层使用,每个组件是一个工程,如登录、分享、推送、IM 等,我们把 Hybrid 框架也归在业务层。其中登录组件我们做成Passport SDK,供公司其他 App 集成调用。
  3. 主业务包括 App 首页、个人中心、各业务线业务和第三方接入业务,业务线业务主要包括发布、大类、列表、详情。

集成管理组件

工程拆分完后,就是工程集成了,我们用 Cocoapods 将各工程集成到一起编译运行和打包,对于每一个工程配置好 .podspec 文件。在配置 podfile 文件时,当用于本地开发时,我们通过 path 的方式进行集成,不用临时下载工程代码,如下所示。

pod proj, :path => '~/58_ios_libs/proj’

在进行 Jenkins 打包时,我们通过 git 方式将代码实时下载:

pod proj, :git => 'git@gitlab.58corp.com:58_ios_team/proj.git',:branch => '1.0.0'。

GitLab 服务进行代码管理

我们在局域网搭建一个 GitLab 服务,用于管理所有工程代码,并设置好开发组及相应的权限。通过 GitLab 还可以实现提交代码审核、代码合并请求及工程分支保护。

第四版架构

随着 58 App 用户量的剧增,各业务线业务迅速增长,对 58 App 又提出了新需求,如为加快大类列表详情页面的渲染速度,需要将原来这些 HTML5 页面 Native 化;再如各业务线要定制列表详情和筛选样式。面对如此众多需求,显然原来的架构已经满足不了,那就需要我们进一步改进客户端架构,将主业务层进一步拆分。

主业务层拆分

我们对主业务层进行一个拆分,拆分后的整体架构如图9所示,其中每一个模块为一个工程,也是一个组件。

图9  第四版架构

图9 第四版架构

我们将首页、发布、发现、消息中心、个人中心及第三方业务等都从主业务层拆分出来成为独立工程。同样将房产、二手、二手车、黄页、招聘等业务线的代码从原工程里面剥离出来,每个业务线独立一工程,将列表和详情分别剥离出来并进行 Native 化,为上层业务线定制功能提供接口。

业务线拆分的时候我们遵循以下几个原则:

  1. 各业务线之间不能有依赖关系,因为我们的业务线在开发的整个过程中都是独立运行的,不会含有其他业务线代码。
  2. 非业务线工程不能对各业务线有依赖关系,即所有业务线都不集成进 App 也要能正常编译。
  3. 各业务线对非业务线工程可以保留必要的依赖,如业务线对列表组件的依赖。

在拆分过程中我们也采取了一些策略,如在拆分招聘业务线时,先把招聘业务线从集成后的工程中删除,进行编译,会出现各种编译错误,说明是有工程对招聘业务线代码进行依赖。如何解决这些依赖关系呢?我们主要是解决相互依赖关系,招聘业务线对非业务线工程肯定是有一定的依赖关系,这个先保留,我们要解决的是其他组件甚至可能是其他业务线对招聘的依赖。我们总结了下,主要用了以下几种方式:

  1. 将依赖的文件或方法下沉,如有些文件并不是招聘业务线专用的,可以从招聘中下沉到其他工程,同样有些方法也可以下沉。
  2. Runtime,这种方式比较普遍,但也不需要所有地方都用,毕竟其维护成本还是比较高的。
  3. Category 方式,如个人中心组件中方法 funA 要调用招聘组件中的方法 funB,但 funB 的实现是要依赖招聘内部代码,这种情况下个人中心是依赖招聘业务线的,理论上招聘可以依赖个人中心,而不应该反过来依赖。解决办法是可以在个人中心添加一个类,如 ClassA,里面添加方法 funB,但实现为空,如果带返回值可以返回一个默认值,再在招聘中添加一个 ClassA 的类别 ClassA+XX,将原来招聘中的方法 funB 放入 ClassA+XX,这样如果招聘集成进来,就会执行 ClassA+XX 中的 funB 方法,否则执行个人中心自己的 funB 方法。

跳转总线

总线包括 UI 总线和服务总线,前者主要处理组件间页面间的跳转,尤其是在主业务层,UI 总线用得比较频繁。服务总线主要处理组件间的服务调用,这里主要讲跳转总线。在主业务层,被封装成的各个组件需要通过 UI 总线进行页面跳转,我们设计了一个总分发中心和子分发中心的模式进行处理,如图10所示。

图10  UI跳转总线

图10 UI 跳转总线

主业务层每个组件内都有一个子分发中心,它的处理逻辑由各组件内来进行,但必须实现一些共同的接口,且这个子分发中心需要进行注册。当组件内需要进行 UI 跳转时,调用总分发中心,将跳转协议传入总分发中心,总分发中心根据协议中组件标识(如业务线标识)找到对应的目标组件子分发中心,将跳转协议透传到对应的子分发中心,接下来的跳转由子分发中心去完成。这样的方式极大降低了组件间的耦合度。

UI 总线中的跳转协议我们原来用 JSON 形式,后来统一调整为 URL 的方式,将 m 调起、浏览器调起、push 调起、外部 App 调起和 App 内跳转统一处理。

新统跳协议 URL 格式如下:

wbmain://jump/job/list? ABMark=markID¶ms=

其中,wbmain 为 58 App的scheme,job 为招聘业务线标识,list 为到列表页,ABMark 为 AB 测跳转用的标识 ID,后面会细讲,params 为传过来的一些参数,如是否需要动画,push 还 present 方式入栈等。为了兼容老协议,我们将原来协议中的一部分内容直接透传到 params 中。

AB 测跳转

对于指定跳转 URL,有时跳转的目标页面是不固定的,如我们的发布页面,有 HTML5 和 React Native 两套页面,如果 React Native 页面出了问题,可以将 URL 做修改跳到 HTML5 页面。具体方案是服务器下发一个路由表,每个表项有一个 ID 和对应新的跳转 URL,每个表项设置有过期时间。跳转的 URL 可以带有 AB 测跳转用的标识 ID,即 markID。如果有这个标识,跳转时就去与路由表中的表项匹配,如果命中就改用路由表中的 URL 跳转,否则还用原来的 URL 执行跳转,大概流程如图11所示。

图11  AB测跳转流程图

图11 AB 测跳转流程图

静态库方案

为了提高整个 App 的编译速度,我们为每个工程配置一个对应的库工程,里面预先由源码工程编译出来一个对应的静态库,如图12所示。

开发人员可以将权限内的源码和静态下载到本地,按需进行源码和库混合集成,如对于招聘业务线 RD,我们只需关心招聘业务线源码工程,不需要其他业务线的源码或静态库,剩下的工程可以选择全部用静态库进行集成。

对于 Jenkins 打包平台,我们也可以根据需求适当在源码和静态库之间做选择。对于一些特殊的工程,如第三方库工程 ThirdComponent,一般也不会变,可以直接接入对应的静态库工程 ThirdComponentLib。

总结

业务在不断变化,需求持续增多,技术也在不断地更新,我们的架构也需要不断进行调整和升级,架构的演进是一项长期的任务。

58同城 iOS 客户端 IM 系统演变历程

文/蒋演

58同城 App 自1.0版本开始,便一直致力于自研 IM 系统。在这过程中,发现如何降低IM系统层次和页面间的耦合,减少 IM 系统的复杂性,是降低技术成本提高研发效率的关键。对此,本文作者对 iOS 客户端 IM 系统架构演变的过程以及经验进行了总结,希望能够给设计或改造优化 IM 模块的开发者提供一些参考。

对于58同城 App 这样以信息展示及交易为主体的平台而言,App 内的 IM 即时消息功能,相比电话和短信,在促成商品/服务交易上更有着举足轻重的地位。也正因如此,自1.0版本开始,便一直致力于自研 IM 系统。在自研过程中,我们发现如何降低 IM 系统层次和页面间的耦合,减少 IM 系统的复杂性,是降低技术成本提高研发效率的关键。

为此,本文将主要从两个方面阐述58同城 iOS 客户端 IM 系统架构的变迁过程。一是 IM 系统如何解除对数据库和 Socket 接口的依赖;二是 IM 聊天页面从传统的 MVC 模式走向面向协议的新型架构。希望给具有相似业务场景的开发者提供一些借鉴。

老版本 IM 系统遇到的问题

58 App 在项目早期就自研了 IM 系统,但只实现了文本消息、图片消息、音频等基本类型。虽然业务需求场景简单,却还是遇到了如下问题。

数据扩展性差

数据格式使用的是 Google ProtocolBuffer(以下简称 PB),是因为这种数据格式相比 XML 和 JSON 相同的数据形式,体积更小,解析更加迅速。但 PB 是用 C++ 实现的,使用起来相对繁琐。需要对不同的消息类型编写不同的 PB 数据结构,每种 PB 结构还需要单独的数据解析方法。由于58业务的发展,这种数据协议增加了系统的复杂性。

代码封装性差,研发成本高

在数据发送前,为了安全,还需要将待传输的数据通过特定的加密算法进行加密,再利用 AsyncSocket 做数据传输。相对应的,每接到一种消息类型,就需要解密,将 PB 格式转换成对象模型。这种方案,每次新增消息类型时都比较痛苦,要写加密算法,写 PB 模型解析器。这样不仅代码的扩展性很差,开发难度也比较大。

代码耦合性强

每次如果有新增消息类型,要在 DB 层写个接口对新消息的数据解析并存储。同样,在 Socket 传输层也要新增收发接口与之对应。这种设计方式,开发过程中耦合性很大。

代码可读性差

App 内只有一种消息类型,叫 WBMessagModel。在消息类型判断上,是通过 WBMessageModel 里的特定字段来进行区分的,比如根据 mtype、isOnlineTip、m _ msgtype 等字段判断,方式相对混乱。

为了解决上面的问题,打造一款低耦合、可扩展性强的 IM 系统,我们决定重构。

新版本的 IM 系统

框架演进

老的 IM 系统由于代码耦合性严重,一旦遇到问题难于追查。并且扩展性差,每个版本的需求研发,都从底层修改到业务层,影响研发进度。结合之前 IM 开发过程中遇到的问题,新的 IM 系统亟需解决如下问题。

  • 简化调用流程

业务开发过程中做到与“底层 DB+数据加密+数据加密+数据传输”的分离,通过调用底层接口就可以做到收发、存储消息。

  • 设计低耦合的中间层接口

中间层接口要做到承上启下对接,业务层和底层接口无任何耦合。如果做到这些,以后在 IM 底层升级甚至更换时,只需调整业务接口与底层接口的重新对接,让顶层的业务无感知,做到无感知的迭代。

  • 设计单一职能的模型和接口

在具体业务层处理上,要做到模型分离,设计统一。模型上,将之前的只有一个 IM 模型根据各自的类型拆分。接口上,通过底层、中间层业务层的结构划分,每层接口各司其职。

  • 可扩展性强

利用面向协议方式抽象和组织代码,做到按照协议新增消息。利用 UITableView 的类别做到现有及新增的消息类型 Cell 能够自动计算高度。通过这种业务上的设计方式,能够快速定位问题。如有新增的消息类型,只需关注新增的消息模型和与之对应的消息界面即可,完全无需关注视图的填充时机以及如何计算视图的高度等。确定了这些设计原则,才能保证在业务研发过程中做到快速迭代,进而满足日益增长的用户需求。

基于上面的目标,重构后的 IM 整体架构图1所示。

图1   新版IM架构设计

图1 新版 IM 架构设计

新的 IM 系统整体架构包含底层、接口服务层、业务层三个部分。底层主要进行数据收发、存储等相关处理,并抽象出通用底层接口,与接口服务层交互。接口服务层主要负责合理地将底层的数据传递到业务层,同样,业务层的数据能够通过接口服务层传递给底层。清晰明了的接口服务层不仅可以让业务层处理数据变得更简单,还能极大地降低业务层和底层的耦合。业务层主要针对具体需求场景,如何合理使用数据进行视图的展示。基于这样的设计,下面详细介绍一下各个层次之间的具体实现。

设计调用流程简洁的底层接口

新的 IM 底层采用了全新的设计思路,如图2所示。在底层,为了数据的可扩展性,放弃了之前 PB 的数据协议,而是采用传统的 JSON 格式作为 Socket 端数据的收发协议。

图2   底层架构设计

图2 底层架构设计

在消息模型上,摒弃了之前只有一种消息模型的策略,而是根据消息类型划分出文本消息模型、图片消息模型等基本消息模型。

58 App 将 DB 和 Socket 的内部处理封装成 SDK,对外只暴露 IMClient 底层接口。顶层所有消息相关的事件都是和底层 IMClient 的接口交互,内部流程完全不用关心。这样业务层完全感知不到数据是如何收发和存储的,极大地简化了接入和使用成本。

但是读者也许会有疑问,IM SDK 里内置了如此多的类型消息,那以后有新增 SDK 里没有的消息类型该怎么办?为了解决这个问题,58 App 采用了一种和 iOS 自定义对象归解档相似的策略——任意定义一种新的消息,只要它继承自基础的消息类型,并遵循 IMMessageCoding 协议。这个协议里定义了 encode 和 decode 方法,其中,encode 方法用于将新类型消息里的数据存储到数据库中(当然,这个过程并不需要上层开发者关注,他们只需在这个函数里返回待存储的数据即可);decode 方法用于将数据库中的数据恢复成相应的消息模型。现在,我们有了消息类型的定义方式,又如何使用呢?为了让底层能够感知到自定义的消息类型,需要在统一接口层 IMClient 初始化之后,立即注册给它,注册后 IM 底层就知道当前的消息类型,并且明白如何存储和恢复数据。基于这种设计方式,目前 58 App 的 IM 底层可以任意扩展其他消息类型,而底层的代码完全不用修改。

底层代码不仅有良好的扩展性,并且在设计时还为一些基础的场景提供了很多协议。这些协议都是可动态定制或移除的。例如,当联系人列表发生变化时,需要修改联系人头像,就可以订制底层 IMClientConversationListUpdateDelegate 协议。使用时,业务方通过注册协议 addUpdateConversationListDelegate:,当监听到联系人更新回掉后,执行头像更新操作。当不需要时,可通过 removeUpdateConversationListDelegate:方式,解除监听。类似的场景还有消息接收协议、在线状态变化协议等。通过这种方式,就可灵活配置业务代码对 IM 的某些状态变化的监听。

目前,通过对底层代码的抽象,提供顶层接口与内部数据处理分离,且很多 IM 服务都可定制化实现,由此就做到了和具体业务无耦合。通过这样的底层设计,完全可以作为基础的 IM SDK,给其他 App 使用,快速集成 IM 功能。

设计低耦合、职责单一的中间层接口

为了业务层和底层能够通信,并且互不耦合,我们创建了中间接口层用以承上启下。根据实际的业务场景,中间接口层分了三种情况,即为登录相关的接口、消息收发相关的接口以及消息查询相关接口,分别和底层统一接口对接。通过业务场景的划分,开发过程中可以快速定位相关业务对应的模块。对于底层提供的消息模型,并没有直接使用,究其原因是底层的消息模型完全不关心视图展示属性,比如行高、重用标识等属性(下节会详细介绍)。而 MVVM 中 VM 部分属性需要和视图关联,因此将底层的消息模型转换成了聊天 Cell 直接可用的消息模型。通过这样的业务接口划分和消息模型的转换,即使之后底层统一接口或消息模型发生变化,只要做好中间接口的重新对接和消息模型的重新转换,顶层业务就完全感知不到下面的变化。

设计可扩展性强的业务层

由于老的 IM 系统项目是早期搭建的,处理的业务场景简单,扩展性不足。例如所有消息都使用同一个数据模型,就会造成随着业务场景的扩展,模型的代码体积越来越大,使用时好多属性冗余不堪。在设计上,老架构使用了 MVC 设计模式,由于在聊天场景下,VC 要处理的聊天视图类型较多,VC 内部十分臃肿。因为之前架构的局限性,这就对新的 IM 业务架构提出了要求,怎样设计出低耦合、扩展性强的业务层?接下来介绍一下具体的实现方案。

拆分 IM 消息模型:明确了上面的问题,现在 58 App 把之前只有一个消息模型,拆分成了文本、图片、语音、提醒、音频、视频等消息模型,它们统一继承基类消息的模型,基类消息模型存储了 IM 所需的必要数据,如聊天用户的信息、消息发送的状态等。

使用 MVVM 架构:为了降低 VC 和各个聊天视图之间的耦合。VC 管理各种消息模型,消息模型中存储视图展示时需要的数据。在消息视图和消息模型之间,实现了双向数据绑定。实现的方式是在聊天视图里存储与之对应的消息模型,这样当聊天视图变化并需要消息模型做数据更新时,直接对消息模型赋值即可。当聊天视图要根据消息模型属性变化而变化时,则通过 KVO 的方式实现这一功能。例如在 IM 场景中,我们发送一条消息,消息模型中的发送状态是发送中,当发送状态变化时(如发送成功或失败),聊天视图就可以根据改变后的值进行更新;

使用面向协议组织 IM 模型和视图:通过面向协议的方式,组织 IM 模型和视图,可以增强 IM 消息模型和视图的扩展性。下文会结合具体的技术细节,阐述面向协议的设计在 58 IM 系统中的重要作用。

技术细节

聊天列表页技术细节

由于 IM 模块的特点,伴随着业务需求的发展,IM 的类型会越来越多。为了避免在研发过程中每次都要花费很多精力计算 UITableView 中 Cell 的高度,为此我们在App 内利用 XIB 创建不同的 Cell,并使用 AutoLayout 的方式给 Cell 中的视图布局。当然,你也可以通过手写代码的方式,然后利用 AutoLayout 布局。而 App 在 IM 中利用 XIB 布局,目的是为了让视图的布局更直观地展示,以及更好地让视图部分和 VC 分离。当 Cell 中所有布局合理完成后,就可以通过调用系统的 systemLayoutSizeFittingSize:方法,获得 Cell 的高度。基于这种思路,58 App 内部给 UITabelView 增加了自动计算 Cell 高度的能力,代码如下:

#import <uikit uikit.h="">#import "WBAutoCalculateTableViewDelegate.h"@interface  NSObject (WBAutoCalculateTableView)@property (nonatomic,assign) CGFloat kid_height;@end@interface UITableView(WBAutoCalculateTableView)- (CGFloat)heightForRowWithReuseIdentifier:(NSString *indentifiercellEntity:(NSObject *)cellEntity;@end</uikit>

首先我们给 NSObject 增加了类别,并在类别里添加了 kid _ height 属性,目的是在计算完 Cell 的高度后,将其缓存好。这样下次重新加载 UITableView 时,就直接返回缓存过的高度。

其次,我们给 UITableView 添加了类别。利用 heightForRowWithReuseIndentifier: Cell Entity:这个 API,在传入当前消息 Cell 的重用标识和当前的消息模型后,就返回当前 Cell 的高度。而调用者完全不用关心高度计算细节,计算完成后,立即将高度利用 NSObject 的类别属性缓存在消息模型中。

为了解决不同类型的消息 Cell 填充数据方式不一致的问题,我们引入了如下协议:

#import <foundation foundation.h="">@protocol  WBCellConfigDelegate<nsobject>@required- (void)setModel:(id)cellEntity;@end</nsobject></foundation>

如此,让 UITableView 中所有的消息 Cell 都遵循此协议,此协议规范了不同的消息 Cell 之间填充数据的统一性。不同的消息 Cell 使用不同类型的消息模型, 但却可以使用相同的填充规范。

@protocol WBAutoCalculateCellViewModelProtocol <nsobject>@required  - (NSString *)cellReuseIndentifier; - (void)registerCellForTableView:(UITableView *)tableView;@optional  - (CGFloat)cellHeight;@end</nsobject>

为了解决消息视图在即将展示时,还要根据当前的消息类型,去判断该使用哪种视图的模板,58 App 采用让每个消息模型遵循上面的协议,每个消息模型都存储与之对应的重用标识。因为 Cell 的注册方式有多种,如通过类注册或 Nib 注册,这里设计成灵活的接口,注册 Cell 方式完全交由开发者决定。

下面的可选协议,在此还要着重在介绍一下- (CGFloat) Cell Height。这个协议是这样的,虽然大部分场景能够自动计算某个 Cell 的高度,但有些消息类型的高度是固定的,根本无需计算。为了解决这个问题,我们给消息模型增加了可选的 Cell Height 协议,如果消息模型实现这个协议,则 Cell 的高度就不自动计算了,通过此方法的返回值决定。

做项目有时就像搭积木一样,通过上面的介绍,我们已经有了很多小的解决方案,就像有了很多积木零件,如何将这些方案组织在一起,下面到了将这些“积木”组装到一起的时候了。因为我们是通过 UITableView 组织和管理聊天页面视图的,而 tableView:heightForRowAtIndexPath:是其重要的代理方法,目前实现如下:

#pragma mark  UITableViewDelegate- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{  CGFloat cellHeight = 0;  id <wbautocalculatecellviewmodelprotocol>cellEntity = self.viewModel.dataSource[indexPath.row];  //向tableview中注册cell 通过 cellindentifier  if ([cellEntity conformsToProtocol:@protocol(WBAutoCalculateCellViewModelProtocol)]) {     if(!self.tableViewRegisters[[cellEntity cellReuseIndentifier]]) {       [cellEntity registerCellForTableView:tableView];       self.tableViewRegisters[[cellEntity cellReuseIndentifier]] = @(1);        }    }    if ([cellEntity respondsToSelector:@selector(cellHeight)]) {        cellHeight = [cellEntity cellHeight];    }else{        cellHeight = [tableView heightForRowWithReuseIdentifier:[cellEntitycellReuseIndentifier] cellEntity:(NSObject *)cellEntity];    }    return cellHeight;}</wbautocalculatecellviewmodelprotocol>

在这个方法中,我们看到了每个 Cell Entity(消息模型),都遵循了上面介绍的 WBAutoCalculate Cell ViewModelProtocol。在此方法里,让每个消息模型去注册自己的 Cell 类型,然后计算 Cell 的高度,如果消息模型有 Cell Height 方法,则通过此方法计算高度,否则通过上面提到的自动算高的方式,返回 Cell 的高度。

在 Cell 的展示处理上,UITableView 的数据源方法 tableview: Cell ForRowAtIndexPath: 是核心的方法,目前实现如下:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{    id <wbautocalculatecellviewmodelprotocol>model = self.viewModel.dataSource[indexPath.row];    NSString *cellIndentifier = [model cellReuseIndentifier];    UITableViewCell<wbcellconfigdelegate> *cell = [tableView dequeueReusableCellWithIdentifier:cellIndentifier];    if (cell &&[cellconformsToProtocol:@protocol(WBCellConfigDelegate)]) {        [cell setModel:model];    }    if (!cell) {        cell =  (UITableViewCell<wbcellconfigdelegate> *)[[UITableViewCell alloc]init];    }    return cell;}</wbcellconfigdelegate></wbcellconfigdelegate></wbautocalculatecellviewmodelprotocol>

通过消息模型找出重用标识。因为已经在 tableView:heightForRowAtIndexPath:时注册过了 Cell,所有通过重用队列一定能返回该消息类型下的 Cell。而消息 Cell 都遵循 WB Cell ConfigDelegate 协议,使得数据在填充时具有统一的方式。

通过面向协议的设计方式,我们在 VC 里 tableView 的代理和数据源方法就变得如此简单。而且以后如果在扩充新的消息类型时,继续遵循相应的协议,VC 里的代码是一行都不用修改的,开发人员只要关和注新增的消息模型和视图即可。

图3   承上启下的业务中间层设计

图3 承上启下的业务中间层设计

处理离线 Voip 消息的技术细节

实际开发过程中,我们遇到了一个问题,当 B 不在线时,B 的聊天对象可能向 B 发起音视频消息,服务器为了信令消息的完备性,会建立一个队列,将所有向 B 发消息的信令记录下来。过了一段时间,当 B 登录时,Server 会把 B 离线期间所有的通话信令发过来。由于刚开始设计时没有考虑到这一点,造成一个问题就是当 B 启动时,A 发送了一个视频消息过来时,B 接受到第一个视频信令是离线期间的视频消息信令(如果有)。这就造成了 B 尝试连接一个早已不存在的视频通道,而让 A-B 视频聊天连接不上。客户端为了也支持这种信令序列,利用条件锁技术有序地处理视频连接信令,如图4所示。

图4   通话信令序列设计

图4 通话信令序列设计

具体解决方案如下:

  • 首先,我们创建一个 Concurrent Queue,当有信令信号传给客户端时,就放在 Concurrent Queue 里执行;
  • 为了保证 Voip 信令能有序执行,我们引入了条件锁 NSCondition, 并行队列在处理 Voip 信号时,先获取条件锁,获取完毕后,我们将 isAvLockActive Bool 变量标记为 YES,然后对信号进行初步处理,初步处理完毕后 Unlock 条件锁;
  • 由于 Unlock 了条件锁,队列里其他的 Voip 信令就有了处理的机会。处理时,检测 isAvLockActive 状态,如果为 YES,说明此前有 Voip 信令还没有处理完毕,则执行条件锁的 wait 方法;

当某个 Voip 信号事件完全处理完毕后,会触发条件锁 Signal,这时,队列里其他等待条件锁的信号就可以得到处理。这时我们又返回步骤2,直至队列里没有待处理的 Voip 信号。

总结

这次 IM 系统重构,通过底层接口分离使得 IM SDK 耦合性降低,利用面向协议设计方式使得聊天页面可扩展性增强,所以短时间内 App 内部扩展了富文本、图片、地理位置、简历、卡片等类型消息。希望通过 58 App IM 的重构历程,能给设计或改造优化 IM 模块的开发者提供一些参考。未来,我们会在如何提高页面性能和降低用户流量上进一步调优,继续完善 IM 的各个细节。

58同城 iOS 客户端 Hybrid 框架探索

文/杜艳新,刘文军

58同城 iOS 客户端的 Hybrid 框架在最初设计和演进的过程中,遇到了许多问题。为此,整个 Hybrid 框架产生了很大的变化。本文作者将遇到的典型问题进行了总结,并重点介绍 58 iOS 采用的解决方案,希望能给读者搭建自己的 Hybrid 框架提供一些参考。

引言

Hybrid App 是指同时使用 Native 与 Web 的 App。 Native 界面具有良好的用户体验,但是不易动态改变,且开发成本较高。对于变动较大的页面,使用 Web 来实现是一个比较好的选择,所以,目前很多主流 App 都采用 Native 与 Web 混合的方式搭建。58同城客户端上线不久即采用了 Hybrid 方式,至今已有六七年。而 iOS 客户端的 Hybrid 框架在最初设计和演进的过程中,随着时间推移和业务需求的不断增加,遇到了许多问题。为了解决它们,整个 Hybrid 框架产生了很大的变化。本文将遇到的典型问题进行了总结,并重点介绍58 iOS 采用的解决方案,希望能给读者搭建自己的 Hybrid 框架一些参考。主要包括以下四个方面:

1)通讯方式以及通讯框架

58 App 最初采用的 Web 调用 Native 的通讯方式是 AJAX 请求,不仅存在内存泄露问题,且 Native 在回调给 Web 结果时无法确定回调给哪个 Web View 。另外,如何搭建一个简单、实用、扩展性好的 Hybrid 框架是一个重点内容。这些内容将在通讯部分详细介绍。

2)缓存原理及缓存框架

提升 Web 页面响应速度的一个有效手段就是使用缓存。58 iOS 客户端如何对 Web 资源进行缓存以及如何搭建 Hybrid 缓存框架将在缓存部分介绍。

3)性能

iOS 8 推出了 WebKit 框架,核心是 WKWebView,其在性能上要远优于 UIWebView,并且提供了一些新的功能,但遗憾的是 WKWebView 不支持自定义缓存。我们经过调研和测试发现了一些从 UIWebView 升级到 WKWebView 的可行解决方案,将在性能部分重点介绍。

4)耦合

58 iOS 客户端最初的 Hybrid 框架设计过于简单,导致 Web 载体页渐渐变得十分臃肿,继承关系十分复杂。耦合部分详细介绍了平稳解决载体页耦合问题的方案。

通讯

Hybrid 框架首先要考虑的问题就是 Web 与 Native 之间的通讯。苹果在 iOS 7 系统推出了 JavaScriptCore.framework 框架,通过该框架可以方便地实现 JavaScript 与 Native 的通讯工作。但是在 58 App 最早引入 Hybrid 时,需要支持 iOS 7 以下的系统版本,所以 58 App 并没有使用 JavaScriptCore.framework,而是采用了更原始的方式。

传统的通讯方式(如图1所示)中,Native 调用 JavaScript 代码比较简单,直接使用 UIWebView 提供的接口 stringByEvaluatingJavaScriptFromString:就可以实现。而 JavaScript 调用 Native 的功能需要通过拦截请求的方式来实现。即 JavaScript 发送一个特殊的 URL 请求,该请求并不是真正的网络访问请求,而是调用 Native 功能的请求,并传递相关的参数。Native 端收到请求后进行判断,如果是功能调 URL 请求则调用 Native 的相应功能,而不进行网络访问。

图1  传统的通讯方式流程

图1 传统的通讯方式流程

按照上面的思路,在实现 Hybrid 通讯时,我们需要考虑以下几个问题:

通讯方式

前端能发起请求的方法有很多种,比如使用 window.open() 方法、AJAX 请求、构造 iframe 等,甚至于使用 img 标签的 src 属性也可以发起请求。58 App 最早是使用 AJAX 请求来发起 Native 调用的,这种方式在最初支撑了 58 App 中 Hybrid 很长一段时间,不过却存在两个很严重的缺陷:

一是内存问题:在 iOS 8 以前,iOS 中内嵌 Web 页都是通过系统提供的 UIWebView 来实现的。而在 UIWebView 中,JavaScript 在创建 XMLHttpRequest 对象发起 AJAX 请求后,会存在内存泄露问题。在实现的应用中,JavaScript 与 Native 的交互操作是很频繁的,使用 XMLHttpRequest 会引起比较严重的内存问题。

二是拦截方法:UIWebView 中的正常 URL 请求会触发其代理方法,我们可以在其代理方法中进行拦截。但是 AJAX 请求是一个异步的数据请求,并不会触发 UIWebView 的代理方法。我们需要自定义 App 中的 NSURLCache 或 NSURLProcotol 对象,在其中可以拦截到URL请求。但是这种方式有两个问题,一个是当收到功能调用请求时,不易确定是哪个 Web View 对象发起的调用,回调时也无法确定调用哪个 Web View 的回调方法。为了解决这个问题,58 App 的 Hybrid 框架维护了一个 Web View 栈,记录所有视图层中的 Web View ,前端在发起 Native 调用时,附加一个 Web View 的唯一标识信息。在 Native 需要回调 JavaScript 方法时,通过 Web View 的唯一标识信息在 Web View 栈中找到对应的 Web View 。另一个是对 App 的框架结构有影响,Hybrid 中的一个简单的调用需要放在 App 的全局对象进行拦截处理,破坏 Hybrid 框架的内聚性,违反面向对象设计原则。

iframe 称作嵌入式框架,和框架网页类似,它可以把一个网页的框架和内容嵌入在现有的网页中。iframe 是在现有的网页中增加一个可以单独载入网页的窗口,通过在 HTML 页面中创建大小为0的 iframe ,可以达到在用户完全无感知的情况下发起请求的目的。使用 iframe 发送请求的代码如下:

var iframe = document.createElement("iframe");//设置iframe加载的页面链接iframe.src = “ http://127.0.0.1/NativeFunction?parameters=values”;//向DOM tree中添加iframe元素,以触发请求document.body.AppendChild(iframe);//请求触发后,移除iframeiframe.parentNode.removeChild(iframe);iframe = null;

iframe 是加载一个新的页面,请求会走 UIWebView 的代理方法,不存在 AJAX 请求中无法确定 Web View 的问题。经过调研测试,多次创建和释放 iframe 不会存在内存泄露的问题。从这两个方面来说,使用 iframe 是远优于使用 AJAX 的,比较有名的 PhoneGap 和 WebViewJavascriptBridge 底层都是采用的 iframe 进行通讯的。

iframe 是前端调用 Native 方法的一个非常优秀的方案,但它也存在一些细微的局限性。58 App 前端为了提升代码的复用性和方便使用 Native 的功能,对 iframe 的通讯方式进行了统一封装,封装的具体实现是——在 JavaScript 代码中动态地向 DOM tree 上添加一个大小为0的 iframe,在请求发起后立刻将其移除。这个操作的前提是 DOM tree 已经形成,也就是说在 DOM Tree 进行之前,这个方案是行不通的。浏览器解析 HTML 的详细过程为:

  1. 接受网络数据;
  2. 将二进制码变成字符;
  3. 将字符变为 Unicode code points;
  4. Tokenizer;
  5. Tree Constructor;
  6. DOM Ready;
  7. Window Ready。

Dom Ready 事件就是 DOM Tree 创建完成后触发的。在业务开发过程中,有少量比较特殊的需求,需要在 DOM Ready 事件之前发起 Native 功能的调用,而动态添加 iframe 的方法并不能满足这种需求。为此,我们对其他几种发起请求的方法进行了调查,包括前文提到的 AJAX 请求、为 window.location.href 赋值、使用 img 标签的 src 属性、调用 window.open() 方法(各个方式的表现结果如表1所示)。

表1 五种方法效果对比

表1  五种方法效果对比

结果显示,其他几种方式除 window.open() 与 iframe 表现基本相同外,都有比较致命的缺陷。AJAX 有内存问题,并且无法使用 Web View 代理拦截请求,window.location.href 在连续赋值时只有一次生效,img 标签不需要添加到 DOM Tree 上也可发起请求,但是无法使用 Web View 代理拦截,并且相同的 URL 请求只发一次。

对于在 DOM Ready 之前需要发起 Native 调用的问题,最终采取的解决方案是尽量避免这种需求。无法避免的进行特殊处理,通过在 HTML 中添加静态的 iframe 来解决。

通讯协议

通讯协议是整个 Hybrid 通讯框架的灵魂,直接影响着 Hybrid 框架结构和整个 Hybrid 的扩展性。为了保证尽量高的扩展性,58 App 中采用了字典的格式来传递参数。一个完整的 Native 功能调用的 URL 如下:

“Hybrid://iframe?parameter={“action”:”changetitle”,”title”:”标题”}

其中“ Hybrid ”是 Native 调用的标识,Native 端在拦截到请求后判断请求URL的前缀是否为“ Hybrid ”,如果是则调起 Native 功能,同时阻止该请求继续进行。 Native 功能调用的相应参数在 parameter 后面的 JSON 数据里,其中“action”字段指明调用哪个 Native 功能,其余字段是调用该功能需要的参数。因为“action”字段名称的原因,后来把为 Web 提供的 Native 功能的处理逻辑称为 action 处理。

这样制定通讯协议有很强的可扩展性,Native 端任意增加新的 Hybrid 接口,只要为 action 字段定一个新值,就可以实现,新接口需要的参数完全自定义。但是这种灵活的协议格式存在一个问题,就是开发者很难记住每种调用协议的参数字段,开发过程中需要查看文档来调用 Native 功能,需要更长的开发时间。为此 58 App 首先建立了健全的协议文档,将每种调用协议都一一列举,并给出调用示例,方便前端开发者查阅。另外,Native 端开发了一套协议数据校验系统,该系统将每种调用协议的参数要求用 XML 文档表示出来,在收到 Native 调用协议数据时,动态地解析数据内部是否符合 XML 文档中的要求,如果不符合则禁止调用 Native 功能,并提示哪里不符合要求。

框架设计

依照上面的通讯协议,58 App 中目前的 Hybrid 的框架设计如图2所示。其中:

图2   Hybrid 框架设计

图2 Hybrid 框架设计

Native 基础服务是 Native 端已有的一些通用的组件或接口,在 Native 端各处都在调用,比如埋点系统、统一跳转及全局 alert 提示框等。这些功能在某些 Web 页面也会需要使用到。

Native Hybrid 框架是整个 Hybrid 的核心部分,其内部封装了除缓存以外的所有 Hybrid 相关的功能。 Native Hybrid 框架可大致分为 Web 载体、Hybrid 处理引擎、Hybrid 功能接口三部分。校验系统是前文提到的在开发过程中校验协议数据格式的模块,方便前端开发者在开发过程中快速定位问题。

Web 载体包含 Web 载体页和 Web View 组件,所有的 Hybrid 页面使用统一的 Web 载体页。Web 载体页提供了所有 Web 页面都可能会使用到的功能,而 Web View 组件为了实现 Web View 的一些定制需求,对系统的 Web View 进行了继承,并重写了某些父类方法。

Hybrid 处理引擎负责处理Web页面发起事件,是 Web View 组件的代理对象,也是 Web 调用 Native 功能的通讯桥梁。前面提到的判断 Web 请求是页面载入请求还是 Native 功能调用请求的逻辑在 Hybrid 处理引擎中实现。在判定请求为 Native 功能调用请求后,Hybrid 处理引擎根据请求参数中的“action”字段的值对该 Native 调用请求进行分发,找到对应的 Hybrid 功能组件,并将参数传递给该组件,由组件进行真正的处理。

Hybrid 功能组件部分包含了所有开放给前端调用的功能。这些功能可以分成两类,一类是需要 Native 基础服务支撑的,另一类是 Hybrid 框架内部可以处理的。需要 Native 基础服务支撑的功能,如埋点、统一跳转、 Native 模块化组件(图片选择、登录等),本身在 Native 端已经有可用的成熟的组件。这些 Hybrid 功能组件所做的事是解析Web页传递过来的参数,将参数转换为 Native 组件可用的数据,并调用相应的 Native 基础服务,将基础服务返回的数据转换格式回调给 Web。另一类 Hybrid 功能组件通常是比较简单的操作,比如改变 Web 载体页的标题和导航栏按钮、刷新或者返回等。这些组件通过代理的方式获取载体页和 Web View 对象,对其进行相应的操作。

再看 Web 端,前端对 Hybrid 通讯进行了一层封装,将发送 Native 调用请求的逻辑统一封装为一个方法,业务层需要调用 Native 功能时调用这个方法,传入 action 名称、参数,即可完成调用。当需要回调时,需要先定义一个回调方法,然后在参数中将方法名带上即可。

缓存

Web 页面具有实时更新的特点,它为 App 提供了不依赖发版就能更新的能力。但是每次都请求完整的页面,增加了流量的消耗,并且界面展示依赖网络,需要更长的时间来加载,给用户比较差的体验。所以对一些常用的不需要每次都更新的内容进行缓存是很重要的。另外,Web 页面需要用到的某些 CSS 和 JavaScript 资源是固定不变的,可以直接内置到 App 包中。所以,在 Hybrid 中,缓存是必不可少的功能。要实现 Hybrid 缓存,需要考虑三个方面的问题,即 Hybrid 缓存实现原理、缓存策略和 Hybrid 缓存框架设计。

缓存实现原理

NSURLCache 是 iOS 系统提供的一个类,每个 App 都存在一个 NSURLCache 的单例对象,即使开发者没有添加任何有关 NSURLCache 的代码,系统也会为 App 创建一个默认的 NSURLCache 单例对象。几乎 App 中的所有网络请求都会调用这个单例对象的 cachedResponseForRequest:方法。该方法是系统从缓存中获取数据的方法,如果缓存中有数据,通过这个方法将缓存数据返回给请求者即可,不必发送网络请求。通过使用 NSURLCache 的自定义子类替换默认的全局 NSURLCache 单例,并重写 cachedResponseForRequest: 方法,可以截获 App 内几乎所有的网络请求,并决定是否使用缓存数据。

当没有缓存可用时,我们在 cachedResponseForRequest: 方法中返回 null。这时系统会发起网络请求,拿到请求数据后,系统会调用 NSURLCache 实例的 storeCachedResponse:forRequest:方法,将请求信息和请求得到的数据传入这个方法。App 通过重写这个方法就可以达到更新缓存的目的。

58 App 目前就是通过替换全局的 NSURLCache 对象,来实现拦截 App 内的 URL 请求。在自定义 NSURLCache 对象的 cachedResponseForRequest:方法中判断请求的 URL 是否有对应的缓存,如果有缓存则返回缓存数据,没有则再正常走网络请求。请求完成后在 storeCachedResponse:forRequest:方法中将请求到的数据按需加入缓存中。

使用替换 NSURLCache 的方法时需要注意替换 NSURLCache 单例对象的时机,一定要在整个 App 发起任何网络请求之前替换。一旦 App 有了网络请求行为,NSURLCache 单例对象就确定了,再去改变是无效的。

缓存策略

Web 的大部分内容是多变的,开发者需要根据具体的业务需求制定缓存策略。好的缓存策略可以在很大程度上弥补 Web 页带来的加载慢和流量耗费大的问题。缓存策略的一般思路是:

  1. 内置通用的资源和关键页面;
  2. 按需缓存常用页面;
  3. 为缓存设置版本号,根据版本号进行使用和升级。

58 App 中对一些通用资源和十分重要的 Web 页面进行了内置,防止 App 在首次启动时由于网络原因导致某些重要页面无法展示。在缓存使用和升级的策略上,58 App 除了设置版本号以外,还针对那些已过期但还可用的缓存数据设置了缓存过期阈值。58 App 的详细缓存策略如下:

  1. 将通用 Hybrid 资源(CSS、JS 文件等)和关键页面(比如业务线大类页)附带版本号内置到 App 的特定 Bundle 中;
  2. 在 NSURLCache 单例中拦截到请求后,判断该请求是否带有缓存版本号信息,如果没有,说明该页面不使用缓存,走正常网络请求;
  3. 从缓存库中查找缓存数据,如果有则取出,否则到内置资源中取。如果两者都没有数据,走正常网络请求。并在请求完成后,将结果保存到缓存库中;
  4. 拿到缓存或内置数据后,将请求中带的版本号 v1 与取到数据的版本号 v2 进行对比。如果 v1≤v2,返回取到的数据,不再请求网络;如果 v1>v2 且 v1 – v2 小于缓存过期阈值,则先返回缓存数据以供使用,然后后台请求新的数据并存入缓存;如果 v1>v2 且 v1 – v2 大于缓存过期阈值,走正常网络请求,并在请求完成后,将结果保存到缓存库中。

缓存框架设计

58 App 中 Hybrid 的缓存框架设计如图3所示,其中:

图3   Hybrid 缓存框架设计

图3 Hybrid 缓存框架设计

1)Hybrid 内置资源管理

Hybrid 内置资源管理模块是单独为 Hybrid 的内置资源而创建的。 Hybrid 内置资源单独存放在一个 Bundle 下,这些内置资源主要包括 HTML 文件、JavaScript 文件、CSS 文件和图片。 Hybrid 内置资源管理模块负责解读这个 Bundle,并向上提供读取内置资源的接口,该接口以资源的 URL 和版本号为参数,按照固定的规则进行转换,查找可用的内置资源。

内置资源中除了这些 Web 资源外,还单独内置了一份文件,用于保存 URL 到内置资源文件名和内置资源版本号的映射表。管理模块在收到内置资源请求后,先用 URL 到这个映射表中查找内置资源版本号,比对版本号,然后再通过映射表中查到的文件名读取相应的内置资源并返回。

2)App 缓存库

58 App 内有一个独立的缓存库组件,App 中需要用到的缓存性质的数据都存放在这个库中,便于缓存的统一管理。缓存库内的缓存数据也有版本号的概念,完全可以满足 Hybrid 缓存的需求,且使用十分方便。Hybrid 的缓存数据都使用 App 的缓存库来保存。

3)Hybrid 缓存管理器

Hybrid 缓存管理器是 Hybrid 缓存相关功能的总入口,负责提供 Hybrid 缓存数据和升级缓存数据,所有的 Hybrid 缓存相关的策略都封装在这个模块中。全局的 NSURLCache 实例在收到 Hybrid 请求时会调起 Hybrid 缓存管理器,索取缓存数据。 Hybrid 缓存管理器先到 App 的缓存库中查找可用的缓存,如果没有再到内置资源管理模块查找,如果可以查到数据,则返回查到的数据,如果查不到,则返回空。在 NSURLCache 的 storeCachedResponse:forRequest: 方法中,会调用 Hybrid 缓存管理器的缓存升级接口,将请求到的数据传入该接口。新请求到的数据会带有最新的版本号信息。缓存升级接口将新的数据和版本号信息一同存入缓存库中,以便下次使用。

性能

前面分享了 58 App 中 Hybrid 的通讯框架和缓存框架,接下来介绍一下遇到的性能方面的问题及解决方案。

AJAX 通讯方式的内存泄露问题

前面介绍过在 UIWebView 中使用 AJAX 的方式进行 Native 功能调用,会产生内存泄露问题,《UIWebView Secrets - Part1 - Memory Leaks on Xmlhttprequest》(参考资料1)中给出了一个解决方案,是在 UIWebView 的代理方法 WebViewDidFinishLoad:中添加如下代码:

[[NSUserDefaults standardUserDefaults] setInteger:0 forKey:@"WebKitCacheModelPreferenceKey"];

测试结果显示,这种方法并没有使用 iframe 的效果好。加上拦截方式的局限性,58 App 最终选择的解决方案是使用 iframe 代替 AJAX。

UIWebView 内存问题

使用过 UIWebView 的开发者应该都知道,UIWebView 有比较严重的内存问题。苹果在 iOS 8 推出了 WebKit 框架,其核心是 WKWebView,志在取代 UIWebView。

WKWebView 不仅解决了 UIWebView 的内存问题,且具有更高的稳定性和响应速度,还支持一些新的功能。使用 WKWebView 代替 UIWebView 对提升整个 Hybrid 框架的性能会有很重大的意义。

但是,WKWebView 一直存在一个问题,就是 WKWebView 发出的请求并不走 NSURLCache 的方法。这就导致我们自定义的缓存系统会整个失效,也无法再用内置资源。经过一段时间的摸索和调研,终于找到了可以实现自定义缓存的方法。主要思想是 WKWebView 发起的请求可以通过 NSURLProtocol 来拦截——将自定义的 NSURLProtocol 子类注册到 NSURLProtocol 的方式,可以像之前用 NSURLCache 一样使用缓存或内置数据代替请求结果返回。注册自定义 NSURLProtocol 的关键代码如下:

[NSURLProtocol registerClass:WBCustomProtocol.class];Class cls = NSClassFromString(@"WKBrowsingContextController");SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");if ([(id)cls respondsToSelector:sel]) {    [(id)cls performSelector:sel withObject:@"http"];

代码中从第二行开始,是为了让 WKWebView 发起的请求可以被自定义的 NSURLProtocol 对象拦截而添加的。添加了上面的代码后,就可以在自定义的 NSURLProtocol 子类的方法中截获到 WKWebView 的请求和数据下载完成的事件。

以上方案解决了 WKWebView 无法使用自定义缓存的问题,但是这种方案还存在一些问题,且使用了苹果系统的私有 API,不符合官方规定,在 App 中直接使用有被拒的风险。另外 WKWebView 还有一些其他问题(详情可参见参考资源6)。

目前,58 App 正在准备接入 WKWebView,但是没有决定使用这种方案来解决自定义缓存问题。我们正在逐步减少对自定义缓存的依赖程度,在前面几个版本迭代中,已经逐步去除了内置的 HTML 页面。

页面加载完成事件优化

正常的 Web 页面加载是比较耗时的,尤其是在网络环境较差的情况下。而 Web 的页面文件与样式表、JavaScript 文件以及图片是分别加载的,很有可能界面元素已经渲染完成,但样式表或 JavaScript 文件还没有加载完,这时会出现布局混乱和事件不响应的情况,影响用户体验。为了不让用户看到这种情况,一般 Native 会在加载 Web 资源的过程中隐藏掉 Web View ,或用 Loading 视图遮挡住 Web View 。等到 Web 资源加载完成再将 Web View 展示给用户。系统通过 UIWebViewDelegate的WebViewDidFinishLoad:方法告知 Native 资源加载完成的事件。这个方法在页面用到的所有资源文件全部加载完成后触发。

在实用中发现,一般情况下样式表资源和 JavaScript 资源的加载速度很快,比较耗时的是图片资源(事实是 Native 界面也存在图片加载比较慢的情况,一般 Native 会采用异步加载图片的策略,即先将界面展示给用户,后台下载图片,下载完成后再刷新图片控件)。实际上当 HTML、样式表和 JavaScript 文件加载完成后,整个界面就完全可以展示给用户并允许用户交互了。图片资源加载完成与否并不影响交互。

且这样的逻辑也与 Native 异步加载图片的体验一致。在 WebViewDidFinishLoad: 方法中才展示界面的策略会延长加载时间,尤其在图片很大或网络环境较差的情况下,用户可能需要多等待几倍的时间。

基于以上的考虑,58 App 的 Hybrid 框架专门为 Web 提供了一功能接口,允许 Web 提前通知 Native 展示界面。该功能实现起来很简单,只需单独定义一个 Hybrid 通讯协议,并在 Native 端相应的处理逻辑即可。前端在开发一些图片资源比较多的页面时,提前调用该接口,可以在很大程度上提升用户体验。

耦合

58 App 最初引入 Hybrid 的时候,业务要简单许多, Native 没有现在这么多功能可供 Web 调用,所以最开始设计的 Hybrid 通讯框架也比较简单。由于使用 AJAX 的方式进行通讯,通讯请求的拦截也要在 NSURLCache 中。当时也没有公用的缓存库组件,Hybrid 的缓存功能与内置资源一起写在单独的模块中(最初的 Hybrid 框架如图4所示)。

图4  旧版 Hybrid 框架设计图

图4 旧版 Hybrid 框架设计图

这个框架在 58 App 中存在了很长一段时间,运行比较稳定。但是随着业务的不断增加,这个框架暴露出了一些比较严重的问题。

自定义的 NSURLCache 类中耦合了 Hybrid 的业务逻辑

由于 AJAX 方式的通讯请求要在 NSURLCache 中进行拦截,NSURLCache 在收到请求后,不得不先判断是否是 Hybrid 通讯请求——如果是,则需要将请求转发给 Hybrid 通讯框架处理。另外,为了解决 Native 回调 Web 时无法确定 Web View 的问题,需要维护一个 Web View 的 Web View 栈,App 内所有的 Web View 对象都需要存入到这个栈中。这个栈需要全局存放,但是 Web 载体页和 Hybrid 事件分发器都是局部对象,无法保存这个栈。考虑到 NSURLCache 对象与 Hybrid 有关联且是单例,最终将这个栈保存在了 NSURLCache 的属性中,更加重了 NSURLCache 与 Hybrid 的耦合。

NSURLCache 耦合 Hybrid 业务逻辑的问题随着 iframe 的引入迎刃而解,通讯请求的拦截直接转移到了 Hybrid 事件分发器中。NSURLCache 的职责重新恢复单一,只负责缓存相关的内容。使用 iframe 的通讯方式,Web 在调用 Native 功能的请求是在 UIWebView 的代理方法中截获,系统会将相应的 Web View 通过参数传递过来,不再有无法确定 Web View 的问题,之前的 Web View 栈也没有必要再维护了。iframe 的引入使得 Hybrid 的通讯框架和缓存框架完全分离开来,互不干涉。

Web 载体页臃肿

最初的 Hybrid 框架中,action 处理的具体实现写在了 Web 载体页中,这导致 Web 载体页随着业务的增加变得十分臃肿,内部包含大量的 action 处理代码。另外,由于一些为 Web 提供的功能是针对某些特定业务场景的,写在公用载体页中并不合适,所以开始了使用继承的方式派生出各种各样的 Web 载体页,最终导致 App 内的 View Controller 的继承关系十分混乱,继承层次最多时高达九层。

Web 载体页耦合 action 处理的问题是业务逐步累积的结果,当决定要重构的时候,这里的逻辑已经变得十分庞杂。强行将这两部分剥离困难很大,一方面代码太多,工作量大,另一方面逻辑过于复杂,稍有不慎就会引起 Bug。解决 Web 载体页的问题采取的方案分成两部分:搭建新 Hybrid 框架,逐步淘汰老的框架为了解决 Web 载体页臃肿的问题,更为了提供对 iOS 8 WebKit 框架的支持,提升 Hybrid 性能,58 iOS 客户端重新搭建了一套新的 Hybrid 框架。新 Hybrid 框架严格按照图2所示的结构进行实现。新增的业务使用新的 Hybrid 框架,并逐步将老的业务切换到新的框架上来。

在图2的框架中,为了在增加新的 Hybrid 功能组件时整体框架满足开闭原则,需要解除 Hybrid 处理引擎对 Hybrid 功能组件的依赖。这里采用的设计是,处理引擎不主动添加组件,而是提供全局的注册接口,内部保存一份共享的注册表。各个功能组件在 load 方法中主动向处理引擎中注册 action 名称、功能组件的类名及方法。处理引擎在运行时动态地查阅注册表,找到 action 对应的类名和方法,生成功能组件的实例,并调用相应的处理方法。

按照上面的设计,一个 Web 界面的完整运行流程为:

  1. 程序开始运行,生成全局的 Hybrid 共享注册表(action 名称到类名及方法名的映射),各个 Hybrid 功能组件向注册表中注册 action 名称;
  2. 需要使用 Web 页,应用程序生成 Web 载体页;
  3. Web 载体页生成 Web View 实例和 Hybrid 处理引擎实例,并强持有这两个实例,将处理引擎实例设为 Web View 实例的代理对象,将自身设为处理引擎的代理对象;
  4. Web 页发起 Native 调用请求;
  5. 处理引擎实例截获 Native 调用请求,并在共享注册表中查到可以处理本次请求的类名和方法名;
  6. 处理引擎生成查找到的 Hybrid 功能组件类的实例,强持有之,并将自身的代理对象设为功能组件的代理对象,调用该实例的处理方法;
  7. Hybrid 功能组件解析全部的调用参数,处理请求,并通过代理对象将处理结果回调给 Web 页。
  8. Web 页生命周期完成,释放 Web View 实例、 Hybrid 处理引擎实例、Hybrid 引擎实例释放所有的 Hybrid 功能组件实例。

通过使用组件主动注册和运行时动态查找的方式,固化了新增组件的流程,保证已有代码的完备性,使 Hybrid 框架在增加新的功能上严格遵守开闭原则。

关于注册表,目前是采用全局共享的方式保存。在最初设计时,还有另一种动态组合注册的方案。该方案不使用共享的注册表,而是每一个 Hybrid 处理引擎保存一份独立的注册表,在 Web 载体页生成 Hybrid 处理引擎的时候,根据业务场景选择部分 Hybrid 功能组件注册到处理引擎中。这种动态组合的方案对功能组件的组合进行了细化,每个Web载体页对象根据各自的业务场景按需注册组件。动态组合注册的方案考虑的主要问题是:在 Hybrid 框架中,有许多专用 Hybrid 功能组件,大部分 Web 页并不需要使用这些组件,另外 58 App 被拆分为主 App 和多个业务线共同维护和开发,有一些 Hybrid 功能组件是业务线独有的,其他业务线并不需要使用。动态组合注册的方案可以达到隔离业务线的目的,同时不使用全局注册表,在不使用 Web 页时不占用内存资源,也减小了单张注册表的大小。

现在的 Hybrid 框架采用全局注册方案,而没有采用动态组合注册的方案,原因是动态组合注册方案需要在生成 Web 载体页时区分业务场景,Web 页的使用方必须提供需要注册的组件信息,而这是比较困难的,也加大了调用方调用 Web 页的复杂程度。另外,大部分组件是否会被使用都是处于模糊状态,并不能保证使用或者不使用,这种模糊性越大,使用动态组合注册方案的意义也就越小。

最终 58 App 采用了全局注册的方案,虽然注册表体积较大,但是由于使用散列算法,并不会增加查找的复杂度而影响性能,同时避免了调用方需要区分业务场景的不便,简化了后续的开发成本。

改造原 Hybrid 框架,防止 Web 载体页进一步扩大为了保证业务逻辑的稳定,不能直接淘汰老的 Hybrid 框架,老业务中会有一部分新的需求需要在老的框架上继续扩展。为了防止老的 Web 载体页因为这些新需求进一步扩大,决定将原 Hybrid 通讯框架改装为双向支持的结构。在保持原 Web 功能接口处理逻辑不变的情况下,支持以组件的方式新增 Web 功能接口。具体的实现是在 Hybrid 事件分发器中也添加了与新 Hybrid 框架的处理引擎相似的逻辑,增加了全局共享注册表,支持组件向其中注册。在分发处理中添加了查找和调用注册组件的逻辑。改造后的 Hybrid 事件分发器在收到 action 请求后,先按老的逻辑进行分发,如果分发成功则调用载体页的处理逻辑,如果分发失败,则查找共享注册表,找到可以处理该 action 的组件进行实例化,并调用相应的处理逻辑。

虽然 Web 载体页由于继承的关系变得很分散,但是事件分发器一直只有一份,逻辑比较集中。进了这样的改造后,有效扼制了 Web 载体的进一步扩大,也不再需要使用继承来复用 action 处理逻辑了。

总结

本文重点介绍了 58 App 中 Hybrid 框架在设计和发展过程中遇到的问题及采用的解决方案。目前的 Hybrid 框架是一个比较简单实用的框架,前端没有对 Native 提供的功能进行一一封装,这样可以在扩展新 action 协议时尽量少地改动代码。且封装层次少,执行效率比较高。目前的 Hybrid 框架依然很友好地支撑着58业务的发展,所以暂时还没引入 JavaScriptCore.framework。在未来的发展中,会逐步引入新技术,搭建更好的 Hybrid 。

参考资料

  1. UIWebView Secrets - Part1 - Memory Leaks on Xmlhttprequest
  2. iframe (Inline Frame)
  3. 《网页加载历程详解》
  4. NSURLCache
  5. 《 WKWebView不支持NSURLProtocol吗》
  6. 《 WKWebView那些坑》
58同城 iOS 客户端搜索模块组件化实践
饿了么商家版 iOS 端订单模块的重构之路
稳定性与内存优化――小型团队的 Android 应用质量保障之道
谈 Fuzz 技术挖掘 Android 漏洞
安居客 Android 模块化探索与实践
浅谈 Android 视频编码的那些坑
从源码角度剖析 Android 系统 EGL 及 GL 线程
基于拆分包的 React Native 在 iOS 端加载性能优化
Qunar React Native 大规模应用实践
饿了么移动基础设施建设
携程无线工程技术系列
携程无线离线包增量更新方案实践
美团点评酒旅移动端 Vue.js 最佳实践
前端感官性能的衡量和优化实践
微信全文搜索优化之路
ofo 移动端的过去与未来
基于接口的消息通讯解耦
Retinex 图像增强算法及 App 端移植
使用 Server-Side Swift 开发 RESTful API

阅读全文: http://gitbook.cn/gitchat/geekbook/5a791993aa4fa8335f4ea37a

阅读更多

没有更多推荐了,返回首页