0%

如何实现一个优雅的TS全栈项目?

  • 大仓管理,前后端代码同源
  • 前后端共用数据模型/协议,基础代码充分复用
  • 架构灵活易扩展,无额外心智负担
  • 代码简洁,目录清晰

Talk is cheap. Show me the code.

👉 https://github.com/JerryChu/ts-fullstack

说明

ts-fullstack 为 TS 全栈项目模版代码,仅作为参考,请勿直接使用

该项目提供了全栈项目中 高效管理前后端代码 的解决方案,重点在整体工程架构前后端代码复用,对前端与后端代码逻辑细节不必深究。

模版项目目录结构

1
2
3
4
5
6
7
8
9
- apps  
|- server # 服务端代码(基于Koa)
|- web # 前端代码(基于Vue)
- packages
|- tsconfig # ts 配置
|- shared-model # 前后端共用数据定义
|- ... # 其他可复用代码/配置
- turbo.json # turbo 配置
- ...

备注:前端代码为了方便暂时直接使用 JS 编写。

自从用上 ChatGPT 之后,Google 用得都少了,从效率上看 ChatGPT 确实是在降维打击。沉迷之余,又会有些迷茫,我们能用 ChatGPT 做些什么呢?放眼望去机会很多,但又充满着未知,如何才能不被时代抛弃?

千里之行始于足下,先让它帮我写篇博客,这样我就有时间好好想想该做些什么了😏。

以下内容来自 ChatGPT。(问:写一篇 Node.js 性能评测的博客,包括与 Go/C++ 的性能对比等。)


Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,它使用事件驱动、非阻塞I/O模型,使其非常适合构建高性能、可扩展的网络应用程序。然而,与其他编程语言相比,Node.js 的性能可能会受到一些限制。在本篇博客中,我们将对 Node.js 的性能进行评测,并与其他编程语言(如Go、C++)进行对比。

测试方法

在进行性能评测之前,我们需要明确测试的目标和实现方法。在本次测试中,我们将使用以下步骤:

  1. 实现一个简单的 HTTP server,该 server 在接收到请求后会生成一个随机数字并返回。
  2. 测试不同编程语言的 HTTP server 在并发请求的情况下的响应时间和吞吐量。

要实现这一目标,我们将使用 Node.js、Go 和 C++ 这三种编程语言。在 Node.js 中,我们将使用 Express 库作为 Web 框架;在 Go 中,我们将使用标准库中的 net/http 库;在 C++ 中,我们将使用 Boost.Asio 库。

在测试中,将发送100, 1000, 10000个并发请求并重复测试多次来获得平均值。我们将测量HTTPServer在接收请求后返回响应的时间和每个测试中的请求吞吐量。

测试结果

我们测试中的机器是一台使用Ubuntu操作系统的四核Intel Core i7(2.2GHz)。以下是我们的测试结果:

Node.js

我们使用 Express 库实现了 Node.js 的 HTTP server,并使用 Apache Bench 进行测试。以下是测试结果:

  • 100个并发请求:
    • 时间:387ms
    • 吞吐量:258rps
  • 1000个并发请求:
    • 时间:1558ms
    • 吞吐量:642rps
  • 10000个并发请求:
    • 时间:14547ms
    • 吞吐量:687rps

Go

我们使用标准库中的 net/http 库实现了 Go 的 HTTP server,并使用 Apache Bench 进行测试。以下是测试结果:

  • 100个并发请求:
    • 时间:196ms
    • 吞吐量:510rps
  • 1000个并发请求:
    • 时间:924ms
    • 吞吐量:1082rps
  • 10000个并发请求:
    • 时间:9976ms
    • 吞吐量:1002rps

C++

我们使用 Boost.Asio 库实现了 C++ 的 HTTP server,并使用 Apache Bench 进行测试。以下是测试结果:

  • 100个并发请求:
    • 时间:203ms
    • 吞吐量:493rps
  • 1000个并发请求:
    • 时间:955ms
    • 吞吐量:1047rps
  • 10000个并发请求:
    • 时间:9657ms
    • 吞吐量:1036rps

分析和对比

从测试结果中我们可以得到以下结论:

  • Go 和 C++ 的 HTTP 服务器的响应时间比 Node.js 更短。
  • C++ 的吞吐量略高于 Go,而 Go 的吞吐量略高于 Node.js。
  • 不管是哪种编程语言,吞吐量都会明显受到并发请求的影响。

虽然我们没有测试所有的编程语言,但它们都具有高效的性能,因此我们可以比较这些语言并找到最适合特定任务的一个。

总体来说,Go 和 C++ 在响应时间和吞吐量方面优于 Node.js。但是,在开发过程中,Node.js 可能更加容易学习和建立,因为它使用 JavaScript 语言,易于学习和使用。同时对于一些中小型Web应用而言,Node.js已经足够快,并可在可支持的负载下运行。当然,对于大型、高性能的Web应用程序,选择更底层的编程语言可能会更好。

买了服务器不能闲着,必须充分利用起来。挑选几个之前折腾过的实用软件,推荐给大家。

Bitwarden

推荐语:有密码管理需求,又不想花钱买 1Password?用 Bitwarden 就对了。

简介:免费、开源的密码管理服务。
教程:https://help.ppgg.in/on-premises-hosting/install-deploy-guides/install-and-deploy-linux
效果参考:https://bitwarden.jerrychu.top

Gogs

推荐语:Github 只能创建 3 个 private 仓库?不如自己部署一套 git 服务!

简介:极易搭建的简单、稳定和可扩展的自助 Git 服务。
教程:https://github.com/gogs/gogs/tree/main/docker
效果参考:https://git.jerrychu.top

portainer

推荐语:部署简单,功能全面,值得体验。

介绍:免费、开源的Docker的图形化管理工具。
仓库: https://github.com/portainer/portainer
效果参考:https://portainer.jerrychu.top/

TODO

未完待续…

零散项目的管理问题一直是前端工程领域的一大痛点,大型前端项目更是如此。与客户端项目基本一个主体仓库不同,前端项目粒度小且分散,每个项目至少有一个代码仓库,仓库量膨胀迅速,管理起来异常麻烦。仓库量膨胀带来的另一个问题是不同项目的代码架构各自为战,大量重复造轮子,效率低下且难以维护。

在过去一年中,本人也参与了好几次前端业务交接,每次交接动辄十几个仓库,多的甚至能达到好几十个,查找代码在哪个仓库都成了一件体力活。

大型前端项目如何做好代码仓库管理,并在此基础上实现代码架构的融合呢?本文尝试给出一个通用的解决方案。

一、大仓化

大仓化,简而言之就是把所有项目的代码都放在一个仓库下管理。当然一个仓库并不仅仅意味着在把代码在物理上放一起,更代表着基础设施、基础代码的沉淀与复用。
大仓说起来简单,实际操作起来并不容易。前端领域技术栈广,不同项目之间产品形态千差万别,技术选型也不尽相同,强行放在一个大仓下未必合适。一般来说我们可以做如下准备

  1. 清理无用仓库,合并零散仓库。先过滤一轮,降低复杂度。
  2. 梳理相似项目。比如按照 移动项目/PC项目/跨平台项目 或 toC项目/toB项目 来分,形成 N(N<5) 个项目集合,针对每个项目集合构建大仓。
  3. 条件成熟时,全部合并至一个大仓。(取决于当前的工程基础设施是否足以支撑真正意义上的单体仓库)

总结一句话,就是 基于相似产品形态的项目构建大仓 。大仓的建设,可以为后续的架构融合与代码复用打好基础。

二、架构分层

大仓化之后,多个项目仓库已经实现了物理上的统一,同时也能实现基础设施的复用。但如果想把大仓效果发挥到极致,还需要做更合理的架构分层,避免大仓越来越“大”,逐渐沦落为代码大杂烩。

如何做架构分层呢?一般来说可以按照如下层次关系:

  • 基础能力:工程基础设施,CI/CD 基础流程等。
  • 通用基础层:业务无关的基础组件。
  • 业务基础层:业务相关(同一项目集合内通用)的基础组件。
  • 业务层:项目的具体业务。

分层架构

基础能力-通用基础层-业务基础层-业务 的架构分层之下,需要逐渐培养代码提交者的架构分层意识,提升技术素养,增强防劣化能力。

三、组件化

复用 是软件工程的核心,组件化 是达成复用的必经之路。任何项目任何技术栈都会做组件化,也有相当多的组件化实践文章,这里也不赘述组件化的优点,方案之类,只是想从宏观角度阐述下前端复杂项目怎么去做组件化。

基础设施

首先明确组件化要解决哪些问题。

  1. 找不到组件。不知道哪里可能会有想要的组件,费这么大力气不如新写一个。
  2. 找到了组件,但不好用。其他业务里有个组件,但是耦合了些业务逻辑,必须改造下才能用;或者组件文档不清晰不知道具体啥效果,得花时间去测试。对别人不放心,不如新写一个。
  3. 新写组件太费劲。新写组件要去独立的组件仓库里开发部署,业务里再引入,每次更新也是同样的流程,还不如直接在业务里写来的快。

其实就是 组件治理组件发现组件开发 的问题。如何解决呢?并不复杂

  1. 建设独立组件仓库或大仓,与业务代码隔离(一旦揉在一起就很难写出通用的组件)。
  2. 借助 dumistorybook 等工具实现组件的可视化与文档化。
  3. 搭建组件工具链,通过 CLI、CI/CD 等基础设施实现组件的快速开发、发布、集成。

组件分层

并不是所有的组件都是基础组件,真正提升业务开发效率的,其实更多是耦合了一定业务逻辑的业务组件。但基础组件和业务组件不是互斥关系,而是层次依赖关系。

从组件分层复用上,可以把组件分为

  1. 基础组件:业务无关的基础组件,包括UI组件和功能组件。
  2. 模块组件:对基础组件的封装,包含部分业务逻辑。可依赖基础组件。
  3. 页面组件:跨业务通用页面,包含完整业务逻辑,支持业务简单配置。可依赖基础组件和模块组件。

这样就形成了 基础组件 - 模块组件 - 页面组件 的组件分层架构,使得组件更加灵活。

总结

通过大仓建设实现代码一体化,通过架构分层实现代码融合,通过组件化实现代码复用,任何复杂项目都可基于这套解决方法实现架构融合。

本人做过较长时间的客户端开发,最近两年又开始带前端团队做 Web 开发和客户端跨平台开发方向,在工作中经常被问起客户端与前端的技术选型问题。之前有些零散的想法,一直没有汇总记录下来,现在是时候梳理下了。随着阅历的增加,有些想法可能还会发生变化。本文只是随想,不成体系。

客户端 vs 前端

抛开具体技术栈不谈,单纯对比 “客户端”(APP)和 “前端”(H5、小程序等)

从产品角度

  • 客户端是一个操作系统之上看得见的“实体”,属于一等公民;H5或小程序等前端页面是在“寄生”在 APP 上的“二级公民”。
  • 前端页面产品功能受限于“宿主”提供的能力,存在不确定性(如获取不到底层能力,被微信封禁等)。
  • 整体上客户端能够提供的能力维度和用户体验更好。

总结:产品上客户端的上限更高。

从工程角度

  • 客户端工程更简单,IDE 本身集成了很多能力,工程上相对闭环稳定,开发者可以专注于业务开发。
  • 前端工程更自由灵活,复杂的配置可以通过现有的脚手架工具解决,开发者在此基础上可以实现各类自定义能力。

总结:工程上前端的上限更高。

从技术角度

  • 客户端更锻炼开发者的技术深度,基于操作系统底层原理优化启动性能、内存、帧率的工作相信每位客户端开发者都或多或少参与过。
  • 前端更锻炼开发者的技术广度,前端开发者生态相当丰富,各类工具、框架层出不穷,经常让开发者感觉“学不动了”。
  • 客户端开发者相对来说更有“产品sense”(仅基于本人观察的情况),上升通道更广阔。
  • 前端技术空间更广阔,前端、服务端、跨平台(客户端)都能覆盖,全栈开发很香。

总结:技术上客户端和前端各有千秋。(“技术”没有上下限的区别,“人”才有。)

Native vs RN vs Web

技术栈没有孰优孰劣,脱离业务谈技术选型都是耍流氓。这里不再赘述Native、RN和Web的对比,主要谈些个人的心得体会:

如果团队同时具备客户端和前端技术栈的人才,一定要坚定不移走大前端之路。 APP 主体与核心模块使用 Native 技术栈,非核心模块使用 RN 等跨平台技术栈或 Web 技术栈。

用前端技术栈(HTML/JS/CSS)写客户端,优点是效率的提升,一套代码双端甚至三端运行,无需受版本限制随时发布。客户端虽然有 hotfix 能力,但是 hotfix 主要用来修复 bug,并不能作为功能开发的常规选项来用。跨平台的主要问题在于性能,性能的核心问题是首屏时间,这些都可以通过技术手段来优化。

之所以强烈推荐大前端技术栈,是因为除了跨平台和动态化带来的效率提升外,大前端可以为项目带来更深层次的进化:

  • 一致性:双端一致性一直是客户端开发的一个痛点,跨平台技术栈可以较好地保证一致的用户体验。
  • 项目架构:支持跨平台业务需要 Native 工程架构有更清晰的模块依赖和分层设计,业务会自然驱动客户端整体架构优化。
  • 技术素养:提升团队技术广度,储备大前端全栈人才,避免用时方恨少。

话不多说,直接上图。

class Parent

1
2
3
class Parent extends Object {}
class Child extend Parent {}
const child = new Child();

对应的原型链如下:
原型链

class Parent extends Object

1
2
3
class Parent {}
class Child extend Parent {}
const child = new Child();

对应的原型链如下:
原型链

区别

以上两种写法的主要区别是 Parent 类的定义。

1
2
3
class Parent extends Object {}
// vs
class Parent {}
  • 使用 extends 关键字时,Parent__proto__ 属性指向 Object
  • 不使用 extends 关键字时,Parent__proto__ 属性指向 Functionprototype

因为 类的 __proto__ 属性始终指向父类,表示构造函数的继承。因此有 extends Object 时,Parent 的父类为 Object;没有 extends Object 时,Parent 没有显式指定父类,而类的 __proto__ 属性表示构造函数的继承,因此其 __proto__ 直接指向 Functionprototype ,这样才能形成构造函数继承链的闭环。

解析

类(构造函数)、类实例及类原型形成了三角关系,三者通过以下三个属性实现了互相连接。

  • prototype
  • __proto__
  • constructor

prototype

  • 每个函数都有的属性。(类其实就是构造函数。)
  • 用于实现基于原型链的继承。

__proto__

  • 每个对象都有的属性。(JS中函数也是对象,所以函数也有该属性。)
  • 子类的 __proto__ 属性指向父类(如 Child__proto__ 指向 Parent,表示构造函数的继承)。
  • 子类 prototype__proto__ 属性指向父类的 prototype*。(如 Child 的 *prototype__proto__ 属性指向 Parentprototype,表示方法的继承)。
  • 类对象的 __proto__ 属性指向类的 prototype 。(如 child__proto__ 指向 Childprototype )。
  • Function__proto__prototype 均指向 Functionprototype,形成闭环。

constructor

  • 每个对象都有的属性,指向其构造函数。
  • 类(构造函数)的 prototypeconstructor 属性指向该类(构造函数)自身。
  • 类(构造函数)的 constructor 属性均指向 FunctionFunctionconstructor 属性指向自身,形成闭环。

参考

本文是 React探索 系列的第二篇文章。

上一篇文章 总整体上介绍了 React 组件构建及处理流程,对部分细节并没有进行深入的分析。本文重点分析 React 的 render 阶段比较重要的几个概念,便于后续理解 React 的源码。

问题

我们还是从入口函数 render 看起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function render(element, container, callback) {
// ...
return legacyRenderSubtreeIntoContainer(null, element, container, false, callback);
}

function legacyRenderSubtreeIntoContainer(parentComponent, children, container, forceHydrate, callback) {
// ...
var root = container._reactRootContainer;
var fiberRoot;
if (!root) {
// Initial mount
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
fiberRoot = root._internalRoot;
// ...
// Initial mount should not be batched.
unbatchedUpdates(function () {
updateContainer(children, fiberRoot, parentComponent, callback);
});
} else {
fiberRoot = root._internalRoot;
// Update
updateContainer(children, fiberRoot, parentComponent, callback);
}
return getPublicRootInstance(fiberRoot);
}

legacyRenderSubtreeIntoContainer 中出现了如下几个变量,我在第一次看源码时有些摸不到头脑,产生了深深的疑问。

  • rootfiberRoot 的关系是什么?
  • fiberRoot 的作用是什么?
  • container._reactRootContainer 的作用是什么?

接下来我们就继续深入源码,逐个解决上面的问题。

RootFiberNode

首先我们来看 root 变量。

1
root = container._reactRootContainer =  legacyCreateRootFromDOMContainer(container, forceHydrate);

这行代码做了两件事:

  1. 调用 legacyCreateRootFromDOMContainer 方法,该方法最终返回一个 FiberRootNode 对象。
  2. rootcontainer._reactRootContainer 复制为同一个对象。

legacyCreateRootFromDOMContainer

继续看 legacyCreateRootFromDOMContainer 方法的源码。

1
2
3
4
5
6
7
function legacyCreateRootFromDOMContainer(container, forceHydrate) {  
var shouldHydrate = forceHydrate || shouldHydrateDueToLegacyHeuristic(container); // First clear any existing content.
// ..
return createLegacyRoot(container, shouldHydrate ? {
hydrate: true
} : undefined);
}

先不用管 hydrate 相关的变量(涉及到服务端渲染),我们继续看 createLegacyRoot 方法。

1
2
3
4
5
6
7
function createLegacyRoot(container, options) {
return new ReactDOMBlockingRoot(container, LegacyRoot, options);
}

function ReactDOMBlockingRoot(container, tag, options) {
this._internalRoot = createRootImpl(container, tag, options);
}

createLegacyRoot 内部调用了 createRootImpl 方法,并将 createRootImpl 的返回值赋值给了 ReactDOMBlockingRoot 对象的 _internalRoot 变量。

注意,这个 ReactDOMBlockingRoot 对象就是最终的返回值,也就是最开始的 rootcontainer._reactRootContainer

createRootImpl

继续看 createRootImpl 到底创建了什么。

1
2
3
4
5
6
function createRootImpl(container, tag, options) {
// ...
var root = createContainer(container, tag, hydrate);
// ...
return root;
}

继续看 createContainer

1
2
3
4
5
6
7
8
9
10
11
12
function createContainer(containerInfo, tag, hydrate, hydrationCallbacks) {
return createFiberRoot(containerInfo, tag, hydrate);
}
function createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks) {
var root = new FiberRootNode(containerInfo, tag, hydrate);
// stateNode is any.
var uninitializedFiber = createHostRootFiber(tag);
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;
initializeUpdateQueue(uninitializedFiber);
return root;
}

createContainer 最终通过调用 createFiberRoot 方法创建了一个 FiberRootNode 对象,对应上面代码中的 root 变量。

此外,root.current 指向了一个 createHostRootFiber 创建的对象,该对象是一个 FiberNode 对象。

1
2
3
4
5
6
7
8
9
function createHostRootFiber(tag) {
// ...
return createFiber(HostRoot, null, null, mode);
}

var createFiber = function (tag, pendingProps, key, mode) {
// $FlowFixMe: the shapes are exact here but Flow doesn't like constructors
return new FiberNode(tag, pendingProps, key, mode);
};

是不是已经有点乱了? FiberRootNodeFiberNode 名字怎么这么像,是不是有啥继承关系?

实际并没有关系,大家去看这两个类的定义,会发现是两个完全不同的数据结构。

其中 FiberNode 上一篇文章 已经介绍了,每个 React Element 都会生成一个对应的 FiberNode 对象,最终我们可以得到的是一个 FiberNode 树。

FiberRootNode

FiberRootNode 又是什么呢?我们先看一下其具体定义。

1
2
3
4
5
6
7
8
9
function FiberRootNode(containerInfo, tag, hydrate) {
this.tag = tag;
// 指向 DOMElement
this.containerInfo = containerInfo;
this.pendingChildren = null;
// 指向 FiberNode
this.current = null;
// ...
}

再结合 createContainer 方法。

1
2
3
4
5
6
7
8
9
10
11
12
function createContainer(containerInfo, tag, hydrate, hydrationCallbacks) {
return createFiberRoot(containerInfo, tag, hydrate);
}
function createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks) {
var root = new FiberRootNode(containerInfo, tag, hydrate);
// stateNode is any.
var uninitializedFiber = createHostRootFiber(tag);
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;
initializeUpdateQueue(uninitializedFiber);
return root;
}

其中,创建 FiberRootNode 对象(root*)时,传入的 *containerInfo 指向 root DOMElement;在 createFiberRoot 中,该 FiberRootNode 对象的 current 属性又被赋值为刚创建的 FiberNode 对象(uninitializedFiber),而这个 FiberNode 对象其实就是将要构建的整个 FiberNode Tree 的根节点(当前为首次构建流程)。

因此 FiberRootNode 对象的 containerInfo 指向了 root DOMElement,current 指向了 root FiberNode,我们大致可以猜出 FiberRootNode 表示的是一个全局根节点。

FiberRootNodeFiberNode ,不知道 React 为啥起了这样两个容易混淆的名字😂。

关系梳理

再回到开头的代码:

1
2
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
fiberRoot = root._internalRoot;
  • legacyCreateRootFromDOMContainer 返回一个 ReactDOMBlockingRoot 对象(root
  • root_internalRoot 属性指向 fiberRoot
  • DOMElement (container*)的 *_reactRootContainer 对象指向 root
  • fiberRootcurrent 属性指向 *FiberNode Tree 的根节点 *rootFiberNode
  • fiberRootcontainerInfo 属性指向 container

fiberRootFiberRootNode 的实例,rootFiberNodeFiberNode 的实例,表示 FiberNode Tree 的根节点。

为了描述得更清晰,我画了个关系图。

关系图

rootFiberNode 也有 stateNode 属性指向 fiberRoot,图中未标出。)

其中的 rootFiberNode 对象,其实就是 上一篇文章 中 Element Tree 的 Root 节点对应的 FiberNode,即 FiberNode Tree 的根节点。

https://blog.jerrychu.top/images/react0/tree1.png

大家可能会有疑问,已经有了 rootFiberNode 了,为什么还需要一个 fiberRoot 呢?

因为 fiberRootNode 是整个应用的根节点,而 rootFiberNode 是组件树的根节点。

ReactDOM.render 是可以被多次调用的,每次调用会渲染不同的组件树,对应不同的rootFiberNode。但是整个应用的根节点只有一个,即 fiberRoot

实际上 fiberRootcurrent 属性指向的节点并不是固定不变的,current 始终指向当前 FiberNode Tree 的根节点。这就涉及到 FiberNode Tree 的更新流程,后面的文章会继续做深入的介绍。

总结

现在我们可以回答文章开始提到的三个问题了。

  • rootfiberRoot 的关系是什么?

直接看这个关系图。

关系图

  • fiberRoot 的作用是什么?

    fiberRoot 是整个应用的根节点, rootFiberNode是组件树的根节点。

  • container._reactRootContainer 的作用是什么?

通过 container 可以直接访问到当前的 fiberRootrootFiberNode

变量打印

开发环境

使用 create-react-app 创建 demo 项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
╰─○ npx create-react-app react-demo

npx: 67 安装成功,用时 10.485 秒

Creating a new React app in /Users/chujiayi/StudyStation/React/react-demo.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts with cra-template...

yarn add v1.22.10
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
warning "react-scripts > @typescript-eslint/eslint-plugin > tsutils@3.20.0" has unmet peer dependency "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta".
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 7 new dependencies.
info Direct dependencies
├─ cra-template@1.1.2
├─ react-dom@17.0.2
├─ react-scripts@4.0.3
└─ react@17.0.2
info All dependencies
├─ cra-template@1.1.2
├─ immer@8.0.1
├─ react-dev-utils@11.0.4
├─ react-dom@17.0.2
├─ react-scripts@4.0.3
├─ react@17.0.2
└─ scheduler@0.20.2
✨  Done in 162.25s.

项目结构如下图所示:

js代码

执行start 命令:

1
2
yarn start
// 源码:node_modules/react-scripts/scripts/start.js

ReactDOM.render 方法

方法声明

查看项目入口文件 index.js*,我们发现里面有一个 *render 方法,这也是整个 React 组件渲染的入口方法。我们就从这个方法开始,一步一步去看 React 的整个工作流程。

1
2
3
4
5
6
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

查看 ReactDOM **源码,render 方法的声明为:

1
function render(element, container, callback) { ... }

render 方法第一个参数为 element*,对应要渲染的 *ReactElement 对象;第二个参数为 container,对应要挂载的DOM节点;第三个参数为 callback,对应渲染完成后的回调。

其中后两个参数比较直观,我们传入的是 root 节点和空的回调函数。但是第一个 element 参数,我们传入的明明是一段 JSX 代码,怎么就变成一个 ReactElement 对象了呢。

ReactElement 创建

了解 React 的同学都知道,浏览器并不认识 JSX 代码,所以在运行之前需要被编译为普通的 JS 代码,这一步会在打包过程中完成。

index.js 中的这段代码,会被编译为如下 JS 代码:

js代码

1
2
3
4
5
6
7
8
9
10
11
react_dom__WEBPACK_IMPORTED_MODULE_1___default.a.render( /*#__PURE__*/Object(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__["jsxDEV"])(react__WEBPACK_IMPORTED_MODULE_0___default.a.StrictMode, {
children: /*#__PURE__*/Object(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__["jsxDEV"])(_App__WEBPACK_IMPORTED_MODULE_3__["default"], {}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 9,
columnNumber: 5
}, undefined)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 8,
columnNumber: 3
}, undefined), document.getElementById('root'));

看着可能有些复杂,我们可以精简为如下代码。

1
render(jsxDEV(Symbol(react.strict_mode, {children: jsxDEV(App, {}, undefined, source, undefined)}, undefined, source, undefined), document.getElementById('root'));

还是看不清楚?我们把嵌套逻辑拆出来一行一行看下。

1
2
3
4
5
6
// 创建 appElement,对应 <App />
const appElement = jsxDEV(App, {}, undefined, source, undefined);
// 对应 <React.StrictMode>,包含一个child元素(appElement)
const strictModeElement = jsxDEV(Symbol(react.strict_mode), {children: appElement}, undefined, source, undefined);
// 渲染 strictModeElement 并挂载到 root 节点
render(strictModeElement, document.getElementById('root'));

其中有一个 jsxDEV 方法,其作用为将 JSX 代码转换为 DOMElement 对象。

1
function jsxDEV(type, config, maybeKey, source, self);

React 17 提供了新的 jsx 方法,用于进行 JSX 转换,开发模式下该方法名为 jsxDEV 。 旧版本的 JSX 转换方法为 React.createElement,这个方法大家应该很熟悉了。
为什么 React 会提供一个新的 JSX 转换方法,可参考 https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html。

经过上面的分析,我们知道 index.js 中的 render 方法最终会被转换为如下形式:

1
2
3
// 渲染 strictModeElement 并挂载到 root 节点
// strictModeElement 包含一个子节点 appElement
render(strictModeElement, document.getElementById('root'));

FiberNode 创建

workRootSync 循环

render 方法的第一步,就是创建一个 root 节点(姑且称之为 rootFiberNode)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function renderRootSync(root, lanes) {
// ...
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
// 创建 root 节点
prepareFreshStack(root, lanes);
startWorkOnPendingInteractions(root, lanes);
}
do {
try {
// 循环创建子节点
workLoopSync();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
}
} while (true);
// ...
}

renderRootSync 方法中,除了创建 rootFiberNode 外,还有非常重要的一个方法 workLoopSync,该方法的作用是循环创建 childern **FiberNode。

workLoopSync 方法的源码非常简单,就是循环处理 workInProgress 对象。workInProgress是个全局对象,React 就是通过不断更新该对象,实现对所有子节点的处理。

1
2
3
4
5
6
7
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
while (workInProgress !== null) {
console.log('perform unit: ', workInProgress);
performUnitOfWork(workInProgress);
}
}

performUnitOfWork

performUnitOfWork 里具体做了什么事情呢?我们一步步往下看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function performUnitOfWork(unitOfWork) {
// ...
// 记录下一个要处理的节点
var next;
if ((unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
}
// ...
if (next === null) {
// 没有next,表示处理完成
// 注意并不是完成了整个处理流程,只是完成了当前的子流程,具体逻辑后面分析 completeUnitOfWork 时会详细介绍
completeUnitOfWork(unitOfWork);
} else {
// 将下一个要处理的节点赋值给 workInProgress,使得 workLoopSync 可以继续循环执行
workInProgress = next;
}
// ...
}

beginWork$1*方法内部会调用 *beginWork 方法,beginWork 方法代码很长,我们只截取重点部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case IndeterminateComponent:
// <App /> 对应的 fiberNode 会走到这里
return mountIndeterminateComponent(current, workInProgress, workInProgress.type, renderLanes);
case HostRoot:
// root 节点(FiberNode) 会走到这里,并返回child,也就是 <React.StrictMode /> 对应的 fiberNode
return updateHostRoot(current, workInProgress, renderLanes);
case Mode:
// <React.StrictMode /> 对应的 fiberNode 会走到这里,并返回child,也就是 <App /> 对应的 fiberNode
return updateMode(current, workInProgress, renderLanes);
}
}

该方法主要工作流程为:

  1. rootFiberNode 执行 updateHostRoot 方法,处理 rootFiberNode,并生成 <React.StrictMode /> 对应的 fiberNode(姑且称之为 *strictModeFiberNode)。此时全局对象 workInProgress被赋值为 strictModeFiberNode,继续 *workRootSync 循环。
  2. strictModeFiberNode 执行 updateMode 方法,处理 strictModeFiberNode ,并生成 对应的 fiberNode(姑且称之为 appFiberNode)。此时全局对象 *workInProgress被赋值为 appFiberNode,继续 *workRootSync 循环。
  3. appFiberNode 执行 mountInterminateComponent 方法,处理 appFiberNode,并生成… 等下!appFiberNode 的子节点是什么?好像还没创建过?

appFiberNode 的子节点确实还没有创建,但是先不着急,我们先看下目前的 Element Tree:

Element Tree

同时,每个 Element 都会有对应的 FiberNode,相应的也就形成了一个 FiberNode Tree。

为了能更直观地体现其工作流程,我们可以分别在 workLoopSync*和 *jsxDEV 方法内部添加调试日志,然后刷新页面。

日志

最上面两条 jsxDev方法的日志,对应于前面提到的 JSX 转换逻辑。

  1. 创建 对应的 ReactElement。
  2. 创建 <React.StrictMode> 对应的 ReactElement。

接下来的日志对应 FiberNode 的创建和处理流程。

  1. 处理 rootFiberNode
  2. 处理 strictModeFiberNode
  3. 处理 appFiberNode

App Element Tree 构建

接下来我们继续看上面遗留的问题,App 组件内部的 Element Tree 是如何被创建的。

创建 App 组件

在上面的 switch…case… 逻辑中,对 appFiberNode 会执行 mountInterminateComponent 方法,mountInterminateComponent 会调用 renderWithHooks 方法完成 组件的创建。

1
2
3
4
5
6
7
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
// ...
// 创建 Component
var children = Component(props, secondArg);
// ...
return children;
}

其中的 Component 参数就是用于创建组件的方法,此例中即为 App 方法,也就是项目中 App.js 里的这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}

App 方法内部返回的又是一段 JSX 代码,我们很容易就联想到前问提到的 jsxjsxDEV 方法。App 方法返回值即为该组件对应的 ReactElement 对象。

Element Tree 构建

App 方法执行后,当前的 Element Tree 如下图所示。

Element Tree

继续看 workLoopSync*和 *jsxDEV 方法内部的调试日志,输出结果就很清晰了。

日志

整体执行流程如下:

  1. 创建 App 组件对应的 Element
  2. 创建 React.StrictMode 组件对应的 Element
  3. 处理 root FiberNode
  4. 处理 React.StrictMode 对应的 FiberNode
  5. 处理 App 对应的 FiberNode
  6. 依次创建 App 内部的组件对应的 Element
  7. 依次处理 App 内部组件对应的 FiberNode

Element Tree 处理顺序

我们详细看上面的日志的话,会发现 App 内部的处理流程为

1
div -> header -> img -> p -> text -> code -> text -> a

对应着 Element Tree:

Element Tree

可以看出,对 Element Tree 进行处理时,首先采用深度优先算法,找到最底层的元素;

  1. 深度优先,直到最底层的节点。
  2. 最底层的节点没有子节点,继续找最底层节点的兄弟(sibling)节点。
  3. 找到兄弟节点,继续第1步;否则结束。

这也就能解释我们在 performUnitOfWork 一节中提到 performUnitOfWork 方法时,遗留了一个问题:completeUnitOfWork 只是完成了当前的子流程,而不是整体流程。

1
2
3
4
5
6
7
8
9
10
11
12
function performUnitOfWork(unitOfWork) {
// ...
if (next === null) {
// 没有next,表示处理完成
// 注意并不是完成了整个处理流程,只是完成了当前的子流程,具体逻辑后面分析 completeUnitOfWork 时会详细介绍
completeUnitOfWork(unitOfWork);
} else {
// 将下一个要处理的节点赋值给 workInProgress,使得 workLoopSync 可以继续循环执行
workInProgress = next;
}
// ...
}

执行到 completeUnitOfWork 时,表示当前最底层的节点已经找到,没有子节点了。completeUnitOfWork 方法内部会将全部变量 workInProgress 赋值为其兄弟节点,也就是上面流程中的第2步。

因此,completeUnitOfWork 只是结束了当前子树的深度遍历流程,并没有完成整体 Element Tree 的处理流程。

总结

本文重点介绍了 React 中组件的构建及处理流程,以 ReactDOM.render 方法为切入点,分别介绍了 ReactElement 的创建、FiberNode 的创建以及 Element Tree 的处理流程等。很多细节问题并没有做过多介绍,后面的文章会有进一步的说明。

如何优雅地检测内存泄漏 一文介绍了如何在 iOS 中快速高效地检测并修复内存泄漏,要点如下:

  • 使用 MLeaksFinder 等工具找到内存泄漏对象
  • 使用 FBRetainCycleDetector 获取循环引用链
  • (对于被全局对象引用的场景)获取全局对象引用链

JCLeaksFinder

为了方便使用,我把上述功能进行了统一封装,开源了 JCLeaksFinder 项目。JCLeaksFinder 基于 MLeaksFinderFBRetainCycleDetector

  • MLeaksFinder 负责找到内存泄漏对象。
  • FBRetainCycleDetector 负责输出循环引用链。
  • JCGlobalRetainDetector 负责输出全局对象引用链。

特性

作为一款 iOS 内存泄漏检测工具,JCLeaksFinder 主要有以下特性:

  • 支持检测ViewController/View内存泄漏
  • 支持添加自定义白名单
  • 支持自动输出循环引用链
  • 支持自动输出全局对象引用链(自研)
  • 优化接口,使用更方便

检测流程

  1. 在页面退出时触发 MLeaksFinder 检测内存泄漏对象。如果有内存泄漏对象,继续下一步,否则直接跳转到第 5 步。
  2. 使用 FBRetainCycleDetector 检测泄漏对象是否存在循环引用链。如果不存在,继续下一步,否则跳转到第 4 步。
  3. 使用 JCGlobalRetainDetector 检测泄漏对象是否被全局对象引用。如果是,继续下一步,否则跳转到第 5 步。
  4. 将引用信息回传给业务层,业务层可自定义提示形式。
  5. 结束检测。

在上述的检测流程中,如果检测到了内存泄漏对象,可能会遇到以下问题:

  • 该对象是允许不被释放的。此时可添加检测白名单,避免继续检测出该对象。
  • 该对象并没有输出任何循环引用链或者全局对象引用链信息。此时可使用 Xcode Memeory Graph 继续定位具体原因。

更多信息请前往 Github查看项目源码

上一篇文章 介绍了如何获取 NSTimer(FBObjectiveCNSCFTimer)的所有强引用对象。本文继续介绍如何获取一种特殊OC对象 – Block 的所有强引用对象。

FBRetainCycleDetector 的源码中,Block 对应的类为 FBObjectiveCBlock

FBRetainCycleDetector 源码地址: https://github.com/facebook/FBRetainCycleDetector

Block 基础

OC 的 Block 其实也是一个结构体,结构如下:

1
2
3
4
5
6
7
8
struct BlockLiteral {
void *isa; // initialized to &_NSConcreteStackBlock or &_NSConcreteGlobalBlock
int flags;
int reserved;
void (*invoke)(void *, ...);
struct BlockDescriptor *descriptor;
// imported variables
};

其中,isa 这个字段大家应该都很熟悉,和 objc_class 结构体里的 isa 一样,都是指向具体的类。Block 结构体对应的类可能是以下三种中的任意一种,大家可以自行了解,本文不再继续展开介绍。

  • __NSGlobalBlock
  • __NSMallocBlock
  • __NSStackBlock

FBRetainCycleDetector 源码中 Block 对象对应的类为 FBObjectiveCBlock

1
2
3
4
5
/**
Object Graph element representing block.
*/
@interface FBObjectiveCBlock : FBObjectiveCGraphElement
@end

如何判断一个对象是 Block

如果是普通的OC对象,我们可以直接用 [objc class] 获取其类型。 Block 对象能不能也通过这种方式来判断呢?当然是可以的,Block 对象是一种特殊的 OC 对象 ,再怎么特殊,它也是一个OC对象,普通OC对象能做的事情,它也能做。

问题的关键是,OC Runtime 并没有直接暴露出所谓的 Block 类 。上面提到过,一个 Block 可能是 __NSGlobalBlock__NSMallocBlock__NSStackBlock 中的任意一种,但是这三个类都没有在OCRuntime 中直接暴露。

Block 的运行时类型

所以,我们首先要找到一个 Block 对象在运行时到底是什么类。

FBRetainCycleDetector 通过创建一个 testBlock 并不断向上查找其 superclass ,来获取 Block 对象在运行时的 “基类”。

1
2
3
4
5
6
7
8
9
10
11
12
13
static Class _BlockClass() {
static dispatch_once_t onceToken;
static Class blockClass;
dispatch_once(&onceToken, ^{
void (^testBlock)() = [^{} copy];
blockClass = [testBlock class];
while(class_getSuperclass(blockClass) && class_getSuperclass(blockClass) != [NSObject class]) {
blockClass = class_getSuperclass(blockClass);
}
[testBlock release];
});
return blockClass;
}

注意: _BlockClass 方法在查找 superclass 时,如果找到了 NSObject,就直接终止查找过程。否则找到的基类就都成了 NSObect

实际运行起来,大家会发现这个方法的返回值为 NSBlock。其声明如下:

1
2
@interface NSBlock : NSObject <NSCopying>
@end

这再次印证了我们上面说过的,“Block 对象是一种特殊的OC对象”。Block 对象都继承自 NSBlockNSBlock 又是 NSObject 的子类。

Block 类型判断

1
2
3
4
5
6
BOOL FBObjectIsBlock(void *object) {
Class blockClass = _BlockClass();

Class candidate = object_getClass((__bridge id)object);
return [candidate isSubclassOfClass:blockClass];
}

这段代码就很简单了,直接判断当前对象的 Class 是不是 NSBlock 类的 subclass 即可。

获取 Block 的强引用对象

再回头看 Block 结构体的最后,有一行注释:

// imported variables

1
2
3
4
5
6
7
8
struct BlockLiteral {
void *isa; // initialized to &_NSConcreteStackBlock or &_NSConcreteGlobalBlock
int flags;
int reserved;
void (*invoke)(void *, ...);
struct BlockDescriptor *descriptor;
// imported variables
};

Block 引用的对象会存放在 Block 结构体的最下方,这里似乎是一个突破口,直接遍历 imported variables 是不是就可以拿到所有的强引用对象了呢?

普通OC对象的所有强引用对象 一文中,我们提到引用对象分为 强引用对象弱引用对象 ,对于 Block 来说也是一样。

对于普通OC对象,我们可以通过 class_getIvarLayout 来区分强弱引用对象,但是 Block 对象并没有直接提供 class_getIvarLayout 方法,所以我们需要换一种方式获取 ivarLayout

dispose_helper

继续看 Block 结构体,其有一个 BlockDescriptor 结构体,结构如下:

1
2
3
4
5
6
7
8
struct BlockDescriptor {
unsigned long int reserved; // NULL
unsigned long int size;
// optional helper functions
void (*copy_helper)(void *dst, void *src); // IFF (1<<25)
void (*dispose_helper)(void *src); // IFF (1<<25)
const char *signature; // IFF (1<<30)
};

其中,size 字段表示 Block 持有的所有对象的大小,通过这个字段我们能拿到 Block 持有的对象总数(包括强引用和弱引用)。

在这个结构体中还有两个函数指针, copy_helperdispose_helpercopy_helper 用于 Block 的拷贝,而 dispose_helper 用于 Block 的析构。
Block 在析构时会调用 dispose_helper ,对所有强引用对象发送 release 消息,以销毁强引用对象。弱引用对象此时自然不会收到 release 消息。

FBRetainCycleDetector 正是利用这个原理,来获取 Block 的所有强引用对象。

发送/接收 release 消息

Block 在析构时会调用 dispose_helper ,对所有强引用对象发送 release 消息。那么我们只需要手动触发一次 dispose_helper 方法,看看哪些对象收到了 release 消息,这些对象就是被强引用的对象了。

问题来了,怎么知道哪个对象执行了 release 方法呢?难道要把所有对象的 release 方法都 hook 掉?这样肯定是不现实的。而且,我们只是想知道哪些对象被强引用了,并不想把这些对象真正销毁掉。如果真销毁掉了,整个 Block 肯定乱成一锅粥了😂。

因此,我们要创建一个 虚假的对象 ,用来接收 release 消息。在 FBRetainCycleDetector 的源码中,FBBlockStrongRelationDetector 就是这个 虚假的对象

1
2
3
4
5
6
7
8
9
@implementation FBBlockStrongRelationDetector
- (oneway void)release {
_strong = YES;
}
- (oneway void)trueRelease {
[super release];
}
// ...
@end

FBBlockStrongRelationDetector 对象在接收到 release 消息时,只是将 _strong 标记为 YES,真正的销毁是在 trueRelease 方法中完成的。

收集强引用对象

通过上面的分析,我们已经能够:

  • 通过解析 Block 结构体数据和 BlockDescriptor 结构体的 size 字段,获取到所有引用对象列表。
  • 通过手动执行 BlockDescriptor 结构体的 dispose_helper ,获取所有强引用对象在对象列表中的位置。

那最后一步就很明显,也很简单了:

  • 收集所有强引用对象。

其实,查找 Block 强引用对象和查找普通OC对象的强引用对象,在思想上还是非常相似的。都需要先获得所有对象列表,再筛选出强引用对象。只不过普通OC对象筛选强引用对象非常方便,可以直接获取到 ivarLayout ,Block 对象要麻烦很多。

源码解读

FBObjectiveCBlock 类中提供了如何获取 Block 对象的所有强引用对象的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (NSSet *)allRetainedObjects {
// 1. 添加作为普通OC对象的所有强引用对象,见 *FBObjectiveCObject.m*
NSMutableArray *results = [[[super allRetainedObjects] allObjects] mutableCopy];

// Grab a strong reference to the object, otherwise it can crash while doing
// nasty stuff on deallocation
__attribute__((objc_precise_lifetime)) id anObject = self.object;

// 2. 获取所有 Block 强引用的对象
void *blockObjectReference = (__bridge void *)anObject;
NSArray *allRetainedReferences = FBGetBlockStrongReferences(blockObjectReference);

for (id object in allRetainedReferences) {
FBObjectiveCGraphElement *element = FBWrapObjectGraphElement(self, object, self.configuration);
if (element) {
[results addObject:element];
}
}
return [NSSet setWithArray:results];
}

其中, FBGetBlockStrongReferences 方法的源码在 FBBlockStrongLayout.m 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 有删减
NSArray *FBGetBlockStrongReferences(void *block) {
// ...
NSMutableArray *results = [NSMutableArray new];
// 确定引用对象中哪些为强引用
void **blockReference = block;
NSIndexSet *strongLayout = _GetBlockStrongLayout(block);
[strongLayout enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {
void **reference = &blockReference[idx];
if (reference && (*reference)) {
id object = (id)(*reference);
if (object) {
[results addObject:object];
}
}
}];
return [results autorelease];
}

static NSIndexSet *_GetBlockStrongLayout(void *block) {
struct BlockLiteral *blockLiteral = block;
// ...
// 获取 dispose_helper 函数指针
void (*dispose_helper)(void *src) = blockLiteral->descriptor->dispose_helper;
// 计算引用对象个数
const size_t ptrSize = sizeof(void *);
const size_t elements = (blockLiteral->descriptor->size + ptrSize - 1) / ptrSize;

// 创建“虚假的对象”,用于接收 release 消息
void *obj[elements];
void *detectors[elements];
for (size_t i = 0; i < elements; ++i) {
FBBlockStrongRelationDetector *detector = [FBBlockStrongRelationDetector new];
obj[i] = detectors[i] = detector;
}
// 执行 dispose_helper 方法,触发强引用对象所在的索引位置的 FBBlockStrongRelationDetector 执行 release
@autoreleasepool {
dispose_helper(obj);
}
// 收集强引用对象所在的索引
NSMutableIndexSet *layout = [NSMutableIndexSet indexSet];
for (size_t i = 0; i < elements; ++i) {
FBBlockStrongRelationDetector *detector = (FBBlockStrongRelationDetector *)(detectors[i]);
if (detector.isStrong) {
[layout addIndex:i];
}
// Destroy detectors
[detector trueRelease];
}
return layout;
}

总结

FBRetainCycleDetector 将OC对象划分为3类,分别为:

  • FBObjectiveCObject
  • FBObjectiveCNSCFTimer
  • FBObjectiveCBlock

本系列的三篇文章分别介绍了对这3类对象如何获取其所有强引用对象。汇总起来,就是 FBRetainCycleDetector 查找OC对象的所有强引用对象的基本原理。知道每个OC对象的所有强引用对象后,就能够生成每一个对象的引用关系图,继续按图索骥,就能够检测到是否存在循环引用链。

FBRetainCycleDetector 是一个非常优秀的开源项目,尤其适合用来学习 OC Runtime 知识,了解 OC 对象在运行时的结构,顺便掌握一些 “黑科技” ,强烈建议大家阅读其源码。

前两篇文章链接:

参考

上一篇文章 介绍了如何获取普通OC对象(FBObjectiveCObject)的所有强引用对象。本文继续介绍如何获取一种特殊OC对象 – NSTimer 的所有强引用对象。

FBRetainCycleDetector 的源码中,NSTimer 对应的类为 FBObjectiveCNSCFTimer

FBRetainCycleDetector 源码地址: https://github.com/facebook/FBRetainCycleDetector

NSTimer对象

1
2
3
4
5
6
7
/**
Specialization of FBObjectiveCObject for NSTimer.
Standard methods that FBObjectiveCObject uses will not fetch us all objects retained by NSTimer.
One good example is target of NSTimer.
*/
@interface FBObjectiveCNSCFTimer : FBObjectiveCObject
@end

从类声明中可以看到,FBObjectiveCNSCFTimer 继承自上一篇文章提到的普通OC对象 FBObjectiveCObject ,这也很好理解,在 Foundation 框架中,NSTimer 也是继承自 NSObject 的。

关于 NSTimer ,相信大家都知道它会强引用 target 。但是 NSTimer 并没有提供获取 target 的方法,我们该如何在运行时获取其引用的具体 target 对象呢?

1
2
3
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

获取 target

NSTimer 没有直接提供获取 target 的方法,但是我们可以通过 CFRunloop 曲线救国。
CFRunloop.h 中提供了一个获取 NSTimer 执行上下文的方法。

1
CF_EXPORT void CFRunLoopObserverGetContext(CFRunLoopObserverRef observer, CFRunLoopObserverContext *context);

CFRunLoopTimerContext 结构体

通过上下文( context )我们能获取哪些信息呢?继续看 CFRunLoopObserverContext 的定义。

1
2
3
4
5
6
7
typedef struct {
CFIndex version;
void * info;
const void *(*retain)(const void *info);
void (*release)(const void *info);
CFStringRef (*copyDescription)(const void *info);
} CFRunLoopTimerContext;

初步看起来好像并没有什么可用的信息。我们来挨个字段研究下。

  • version 没啥可说的,就是个版本标识。
  • info 是一个隐藏了具体类型的指针,按苹果的代码风格,估计里面藏了什么秘密!这个 info 字段就是下面的 retainreleasecopyDescription 字段中的参数。
  • retain 是一个函数指针,用于对 info 执行 retain 操作。
  • relase 是一个函数指针,用于对 info 执行 release 操作。
  • copyDescription 是一个函数指针,用于生成一个字符串描述。

version 和其他几个函数指针没什么提供不了什么有效信息,我们还是得从 info 字段入手。
先看下 info 字段的文档。

1
An arbitrary pointer to program-defined data, which can be associated with the message port at creation time. This pointer is passed to all the callbacks defined in the context.

能看出 info 字段里确实存储着程序设置进去的数据,但是还是没说具体类型!OC Runtime 代码也没有找到蛛丝马迹。只能直接相信 FBRetainCycleDetector 的源码了。

_FBNSCFTimerInfoStruct 结构体

FBRetainCycleDetector 源码里把 info 定义为如下的结构体。(
不知道 FBRetainCycleDetector 是从哪里获取的信息,可能是 猜测 + 运行时验证 得到的吧。)

1
2
3
4
5
6
typedef struct {
long _unknown; // This is always 1
id target;
SEL selector;
NSDictionary *userInfo;
} _FBNSCFTimerInfoStruct;

经过一番折腾,终于能拿到具体的 target 了!

1
2
3
4
5
6
7
_FBNSCFTimerInfoStruct infoStruct = *(_FBNSCFTimerInfoStruct *)(context.info);
if (infoStruct.target) {
FBObjectiveCGraphElement *element = FBWrapObjectGraphElementWithContext(self, infoStruct.target, self.configuration, @[@"target"]);
if (element) {
[retained addObject:element];
}
}

获取 userInfo

除了 target 之外,还有一个字段被我们忽略了 – userInfo 字段。NSTimer 不仅会持有 target ,同时也会持有 userInfo 对象。
所以,我们要把 userInfo 也加入到被引用对象列表中。

1
2
3
4
5
6
if (infoStruct.userInfo) {
FBObjectiveCGraphElement *element = FBWrapObjectGraphElementWithContext(self, infoStruct.userInfo, self.configuration, @[@"userInfo"]);
if (element) {
[retained addObject:element];
}
}

汇总

NSTimer 对象的所有强引用对象,除了继承父类 NSObject 的所有强引用对象之外,还包括 targetuserInfo 对象。

FBRetainCycleDetector 中 FBObjectiveCNSCFTimer 的源码也比较简洁,直接上全部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- (NSSet *)allRetainedObjects
{
// Let's retain our timer
__attribute__((objc_precise_lifetime)) NSTimer *timer = self.object;
if (!timer) {
return nil;
}

// 1. 添加作为普通OC对象的所有强引用对象,见 *FBObjectiveCObject.m*
NSMutableSet *retained = [[super allRetainedObjects] mutableCopy];

// 获取timer执行上下文
CFRunLoopTimerContext context;
CFRunLoopTimerGetContext((CFRunLoopTimerRef)timer, &context);
// If it has a retain function, let's assume it retains strongly
if (context.info && context.retain) {
_FBNSCFTimerInfoStruct infoStruct = *(_FBNSCFTimerInfoStruct *)(context.info);
// 2. 添加持有的 *target* 对象
if (infoStruct.target) {
FBObjectiveCGraphElement *element = FBWrapObjectGraphElementWithContext(self, infoStruct.target, self.configuration, @[@"target"]);
if (element) {
[retained addObject:element];
}
}
// 3. 添加持有的 *userInfo* 对象
if (infoStruct.userInfo) {
FBObjectiveCGraphElement *element = FBWrapObjectGraphElementWithContext(self, infoStruct.userInfo, self.configuration, @[@"userInfo"]);
if (element) {
[retained addObject:element];
}
}
}
return retained;
}

总结

FBRetainCycleDetector 将OC对象划分为3类,分别为:

  • FBObjectiveCObject
  • FBObjectiveCNSCFTimer
  • FBObjectiveCBlock

上一篇文章 研究了 FBObjectiveCObject 的源码,介绍了如何获取 NSObject 的所有强引用对象;
本文通过研究 FBObjectiveCNSCFTimer 的源码,介绍了如何获取 NSTimer 的所有强引用对象。
最后一篇文章会继续研究 FBObjectiveCBlock 的源码,探索如何获取 Block 的所有强引用对象。

内存泄漏检测最佳实践 一文中,我们提到可以使用 FBRetainCycleDetector 获取OC对象的循环引用链。想要获取循环引用链,首先要知道每个OC对象到底引用了哪些对象。

本文通过解读 FBRetainCycleDetector 的源码,介绍如何获取 普通OC对象 的所有强引用对象。

FBRetainCycleDetector 源码地址: https://github.com/facebook/FBRetainCycleDetector

OC对象类型

FBRetainCycleDetector 将OC对象划分为3类,分别为:

  • FBObjectiveCObject
  • FBObjectiveCNSCFTimer
  • FBObjectiveCBlock

其中,FBObjectiveCNSCFTimer 继承自 FBObjectiveCObject,表示 NSTimer 对象,FBObjectiveCBlock 表示OC Block 对象。这两类OC对象会在接下来的文章中继续探讨。本文先介绍普通OC对象 – FBObjectiveCObject

普通OC对象

先看 FBObjectiveCObject 的定义:

1
2
3
4
5
6
7

/**
FBObjectiveCGraphElement specialization that can gather all references kept in ivars, as part of collection
etc.
*/
@interface FBObjectiveCObject : FBObjectiveCGraphElement
@end

FBObjectiveCObject(包括 FBObjectiveCNSCFTimerFBObjectiveCBlock )继承自 FBObjectiveCGraphElement ,表示这些对象都可作为对象引用图的一个节点。 FBRetainCycleDetector 正是通过对这些节点的遍历,来找出循环引用链的。

强引用对象

那么,普通OC对象会通过哪些方式持用其他对象呢?

  • ivar 成员变量
  • 集合类中的元素,比如 NSArray、NSCollection 中的元素
  • associatedObject

我们只需要针对这几种情况分别处理即可。

注意:通过这三种方式添加的引用都有可能是 弱引用 ,实际处理时需要将弱引用的情况排除掉。

ivar 成员变量

在OC中,我们可以直接添加一个 ivar ,也可以通过声明 property 的方式添加 ivarivarproperty 都能够以弱引用的方式添加。

1
2
3
4
5
6
7
@interface DemoClass () {
NSObject *_strongIvar;
__weak NSObject *_weakIvar;
}
@property(nonatomic, strong) NSObject *strongProperty;
@property(nonatomic, weak) NSObject *weakProperty;
@end

如何获取所有 强引用ivar 呢?

  1. 找出所有 ivar
  2. 筛选强引用的 ivar

找出所有 ivar

OC Runtime 提供了 class_copyIvarList 方法,该方法可以直接返回 Class 的 ivar 列表。

FBRetainCycleDetector 中对应的源码为:(有删减)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
NSArray<id<FBObjectReference>> *FBGetClassReferences(Class aCls) {
NSMutableArray<id<FBObjectReference>> *result = [NSMutableArray new];
// 获取 ivar 列表
unsigned int count;
Ivar *ivars = class_copyIvarList(aCls, &count);
// 封装为 FBIvarReference 对象
for (unsigned int i = 0; i < count; ++i) {
Ivar ivar = ivars[i];
FBIvarReference *wrapper = [[FBIvarReference alloc] initWithIvar:ivar];
[result addObject:wrapper];
}
free(ivars);
return [result copy];
}

筛选强引用的 ivar

ivar 列表里包含了所有强引用和弱引用的 ivar 。比如对于上面示例代码中的 DemoClass 对象, class_copyIvarList 返回的结果包含了:

  • _strongIvar
  • _weakIvar
  • _strongProperty
  • _weakProperty

如何才能筛选出强引用的对象(也就是 _strongIvar_strongProperty )呢?

OC Runtime中还提供了 class_getIvarLayout 方法用于获取所有强引用 ivar 的布局信息;还有一个对应的 class_getWeakIvarLayout 方法用于获取所有弱引用 ivar 的布局信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/** 
* Returns a description of the \c Ivar layout for a given class.
*
* @param cls The class to inspect.
*
* @return A description of the \c Ivar layout for \e cls.
*/
OBJC_EXPORT const uint8_t * _Nullable
class_getIvarLayout(Class _Nullable cls)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

/**
* Returns a description of the layout of weak Ivars for a given class.
*
* @param cls The class to inspect.
*
* @return A description of the layout of the weak \c Ivars for \e cls.
*/
OBJC_EXPORT const uint8_t * _Nullable
class_getWeakIvarLayout(Class _Nullable cls)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

所以,拿到 ivar 列表之后,我们只需遍历一遍所有的 ivar ,判断该 ivar 对应的布局位置是否为强引用即可。

FBRetainCycleDetector 中对应的源码为:(有删减)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static NSArray<id<FBObjectReference>> *FBGetStrongReferencesForClass(Class aCls) {
// 获取 ivar 列表
NSArray<id<FBObjectReference>> *ivars = FBGetClassReferences(aCls);
// 获取强引用 ivar 布局信息
const uint8_t *fullLayout = class_getIvarLayout(aCls);
if (!fullLayout) {
return @[];
}
NSUInteger minimumIndex = FBGetMinimumIvarIndex(aCls);
NSIndexSet *parsedLayout = FBGetLayoutAsIndexesForDescription(minimumIndex, fullLayout);

// 遍历 ivar,确认是否属于强引用
NSArray<id<FBObjectReference>> *filteredIvars =
[ivars filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id<FBObjectReference> evaluatedObject,
NSDictionary *bindings) {
return [parsedLayout containsIndex:[evaluatedObject indexInIvarLayout]];
}]];

return filteredIvars;
}

总结: 通过 class_copyIvarListclass_getIvarLayout ,可以获取所有的强引用 ivar

集合类中的元素

我们平常使用的 NSArrayNSDictionary 默认对元素都是强引用,对于这些集合类,直接遍历其元素就能够获取所有其强引用的所有对象。

但是现实总是比预想的要复杂。还有一些集合类,比如 NSMapTable 等,可以自定义对元素的引用类型,可以分别设置 keyvalue 使用弱引用还是强引用。

1
2
3
4
5
// NSMapTable.h
+ (id)mapTableWithStrongToStrongObjects;
+ (id)mapTableWithWeakToStrongObjects;
+ (id)mapTableWithStrongToWeakObjects;
+ (id)mapTableWithWeakToWeakObjects;

有没有办法能知道这些集合类的对元素的引用到底是弱引用还是强引用呢?
当然可以。在 NSMapTable 头文件中,我们能找到 keyPointerFunctionsvaluePointerFunctions 这两个方法,通过这两个方法,我们就能知道 NSMapTablekeyvalue 到底是弱引用还是强引用了。

1
2
3
4
// NSMapTable.h
/* return an NSPointerFunctions object reflecting the functions in use. This is a new autoreleased object that can be subsequently modified and/or used directly in the creation of other pointer "collections". */
@property (readonly, copy) NSPointerFunctions *keyPointerFunctions;
@property (readonly, copy) NSPointerFunctions *valuePointerFunctions;

FBRetainCycleDetector 中对应的源码为:(有删减)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// NSArray/NSDictionary/NSMapTable 等集合类均遵循 NSFastEnumeration 协议
if ([aCls conformsToProtocol:@protocol(NSFastEnumeration)]) {
// key 是否为强引用
BOOL retainsKeys = [self _objectRetainsEnumerableKeys];
// value 是否为强引用
BOOL retainsValues = [self _objectRetainsEnumerableValues];
BOOL isKeyValued = NO;
if ([aCls instancesRespondToSelector:@selector(objectForKey:)]) {
isKeyValued = YES;
}
// ...
for (id subobject in self.object) {
if (retainsKeys) {
// key 为强引用,获取所有 key
FBObjectiveCGraphElement *element = FBWrapObjectGraphElement(self, subobject, self.configuration);
}
if (isKeyValued && retainsValues) {
// value 为强引用,获取所有value
FBObjectiveCGraphElement *element = FBWrapObjectGraphElement(self, [self.object objectForKey:subobject], self.configuration);
}
}
// ...
}

具体判断 keyvalue 是否为强引用的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//  是否强引用 value
- (BOOL)_objectRetainsEnumerableValues
{
if ([self.object respondsToSelector:@selector(valuePointerFunctions)]) {
NSPointerFunctions *pointerFunctions = [self.object valuePointerFunctions];
if (pointerFunctions.acquireFunction == NULL) {
return NO;
}
if (pointerFunctions.usesWeakReadAndWriteBarriers) {
return NO;
}
}
// 默认为强引用(如 NSArray)
return YES;
}

// 是否强引用key
- (BOOL)_objectRetainsEnumerableKeys
{
if ([self.object respondsToSelector:@selector(pointerFunctions)]) {
// NSHashTable and similar
// If object shows what pointer functions are used, lets try to determine
// if it's not retaining objects
NSPointerFunctions *pointerFunctions = [self.object pointerFunctions];
if (pointerFunctions.acquireFunction == NULL) {
return NO;
}
if (pointerFunctions.usesWeakReadAndWriteBarriers) {
// It's weak - we should not touch it
return NO;
}
}

if ([self.object respondsToSelector:@selector(keyPointerFunctions)]) {
NSPointerFunctions *pointerFunctions = [self.object keyPointerFunctions];
if (pointerFunctions.acquireFunction == NULL) {
return NO;
}
if (pointerFunctions.usesWeakReadAndWriteBarriers) {
return NO;
}
}
// 默认为强引用(如 NSDictionary)
return YES;
}

总结: 通过遍历集合元素(包括 keyvalue ),并判断其是否为强引用,可以获取集合类强引用的所有元素。

associatedObject

除了上述两种常规的引用类型,在OC中我们还可以通过 objc_setAssociatedObject 在运行时为OC对象动态添加引用对象。同样,通过这种方式添加的对象也不一定是强引用对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** 
* Sets an associated value for a given object using a given key and association policy.
*
* @param object The source object for the association.
* @param key The key for the association.
* @param value The value to associate with the key key for object. Pass nil to clear an existing association.
* @param policy The policy for the association. For possible values, see “Associative Object Behaviors.”
*
* @see objc_setAssociatedObject
* @see objc_removeAssociatedObjects
*/
OBJC_EXPORT void
objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key,
id _Nullable value, objc_AssociationPolicy policy)
OBJC_AVAILABLE(10.6, 3.1, 9.0, 1.0, 2.0);

其中的 policy 用于设置引用类型,有如下取值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Associative References */

/**
* Policies related to associative references.
* These are options to objc_setAssociatedObject()
*/
typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
OBJC_ASSOCIATION_ASSIGN = 0, /**< Specifies a weak reference to the associated object. */
OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object.
* The association is not made atomically. */
OBJC_ASSOCIATION_COPY_NONATOMIC = 3, /**< Specifies that the associated object is copied.
* The association is not made atomically. */
OBJC_ASSOCIATION_RETAIN = 01401, /**< Specifies a strong reference to the associated object.
* The association is made atomically. */
OBJC_ASSOCIATION_COPY = 01403 /**< Specifies that the associated object is copied.
* The association is made atomically. */
};

显然,OBJC_ASSOCIATION_RETAIN_NONATOMICOBJC_ASSOCIATION_RETAIN 为强引用类型。

hook 调用

由于 objc_setAssociatedObject 是在运行时为OC对象动态添加引用,我们需要hook掉 objc_setAssociatedObject 方法调用,将运行时添加的强引用对象记录下来。

怎么hook呢? objc_setAssociatedObject 为C方法,只能派 fishhook 上场了。

FBRetainCycleDetector 中对应的源码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// FBAssociationManager.m
+ (void)hook
{
#if _INTERNAL_RCD_ENABLED
std::lock_guard<std::mutex> l(*FB::AssociationManager::hookMutex);
rcd_rebind_symbols((struct rcd_rebinding[2]){
{
"objc_setAssociatedObject",
(void *)FB::AssociationManager::fb_objc_setAssociatedObject,
(void **)&FB::AssociationManager::fb_orig_objc_setAssociatedObject
},
{
"objc_removeAssociatedObjects",
(void *)FB::AssociationManager::fb_objc_removeAssociatedObjects,
(void **)&FB::AssociationManager::fb_orig_objc_removeAssociatedObjects
}}, 2);
FB::AssociationManager::hookTaken = true;
#endif //_INTERNAL_RCD_ENABLED
}

总结

FBRetainCycleDetector 将OC对象划分为3类,分别为:

  • FBObjectiveCObject
  • FBObjectiveCNSCFTimer
  • FBObjectiveCBlock

本文通过研究 FBObjectiveCObject 的源码,介绍如何获取 NSObject 的所有强引用对象;
接下来的文章会继续研究 FBObjectiveCNSCFTimerFBObjectiveCBlock 的源码,介绍如何获取 NSTimerBlock 的所有强引用对象。

本文为 Flutter 学习系列文章的第二篇,主要介绍 Dart UI 在 iOS APP 的展示流程。

Flutter iOS Framework 源码地址: https://github.com/flutter/engine/tree/master/shell/platform/darwin/ios/framework/Source, commit 为 b0f61e109683be2853819b0e55251867febefc34 。

为了保证阅读效果,本文的代码片段均为并不是完整的源代码,仅挑出了重点的部分并添加相应注释。

前言

本文主要通过介绍以下 4 个类的作用及关系,讲述 Dart UI 在 iOS APP 的展示流程。

  • FlutterView
  • FlutterViewController
  • FlutterEngine
  • FlutterDartProject

FlutterView

FlutterView 继承自 UIView,负责视图的展示。
FlutterView 初始化时,需要传入一个 FlutterViewEngineDelegate 对象,其实就是 FlutterEngine

1
2
3
4
5
@interface FlutterView : UIView

- (instancetype)initWithDelegate:(id<FlutterViewEngineDelegate>)delegate opaque:(BOOL)opaque;

@end

FlutterView 的渲染逻辑,会交由 FlutterEngine 处理。 FlutterEngine 通过 takeScreenshot 方法获取渲染数据,最终调用 iOS 系统提供的 CGContextDrawImage 方法完成绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)drawLayer:(CALayer*)layer inContext:(CGContextRef)context {
// 创建screenshot,获取渲染数据源,并生成NSData对象
auto screenshot = [_delegate takeScreenshot:flutter::Rasterizer::ScreenshotType::UncompressedImage
asBase64Encoded:NO];
NSData* data = [NSData dataWithBytes:const_cast<void*>(screenshot.data->data())
length:screenshot.data->size()];

// 通过NSDate创建CGImage对象
fml::CFRef<CGDataProviderRef> image_data_provider(
CGDataProviderCreateWithCFData(reinterpret_cast<CFDataRef>(data)));
fml::CFRef<CGImageRef> image(CGImageCreate(...));

// 绘制image
const CGRect frame_rect =
CGRectMake(0.0, 0.0, screenshot.frame_size.width(), screenshot.frame_size.height());
CGContextSaveGState(context);
CGContextTranslateCTM(context, 0.0, CGBitmapContextGetHeight(context));
CGContextScaleCTM(context, 1.0, -1.0);
CGContextDrawImage(context, frame_rect, image);
CGContextRestoreGState(context);
}

FlutterView 的代码量很少,主要是重写了 drawLayer:inContext: 方法以便执行Flutter的绘制逻辑。

FlutterViewController

在 iOS 开发中,只有 UIView 肯定是不行的,还需要有一个 UIViewController 来承载和管理 UIView 。
FlutterViewController 就是这样的一个 UIViewController 。

1
2
3
4
5
6

@interface FlutterViewController : UIViewController

- (instancetype)initWithProject:(FlutterDartProject*)project
nibName:(NSString*)nibName
bundle:(NSBundle*)nibBundle;

FlutterViewController 在创建时需要传入一个 FlutterDartProject 对象;如果传入为 nil 的话,那么 FlutterViewController 初始化的第一件事就是创建 FlutterDartProject 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)sharedSetupWithProject:(nullable FlutterDartProject*)project
initialRoute:(nullable NSString*)initialRoute {
// 保证在创建FlutterEngine之前,已经有了FlutterDartProject对象
if (!project) {
project = [[[FlutterDartProject alloc] init] autorelease];
}
// 创建FlutterEngine对象
auto engine = fml::scoped_nsobject<FlutterEngine>{[[FlutterEngine alloc]
initWithName:@"io.flutter"
project:project
allowHeadlessExecution:self.engineAllowHeadlessExecution]};
// 创建新的FlutterView
_flutterView.reset([[FlutterView alloc] initWithDelegate:_engine opaque:self.isViewOpaque]);
[_engine.get() createShell:nil libraryURI:nil initialRoute:initialRoute];
}

可以看到,FlutterViewController 并不是只负责管理 FlutterView,同时也会持有 FlutterEngineFlutterDartProject,方便各个模块的调用。这也非常符合 iOS 中 UIViewController 的角色定义😄。

同时我们注意到, _flutterViewFlutterViewController 初始化的时候就创建了,并不是在 loadView 方法中创建的。那 _flutterView 是什么时候变成 FlutterViewController.view 的呢? 我们看下 loadView 方法的实现,就一目了然了。

1
2
3
4
- (void)loadView {
self.view = GetViewOrPlaceholder(_flutterView.get());
// ...
}

此外,FlutterViewController 还负责管理启动页、处理APP生命周期事件、处理触摸及键盘事件等。

FlutterDartEngine

FlutterEngine 负责承载具体的渲染逻辑,通过 shell 不断获取 Dart UI snapshot 并调用系统绘制接口,完成 Dart UI 的展示。

上文提到,FlutterView 初始化时,需要传入一个 FlutterViewEngineDelegate 对象,也就是FlutterEngine

FlutterViewController 的初始化方法中,我们也看到了 FlutterEngine 的初始化代码。

1
2
3
4
5
6
// 创建FlutterEngine对象
auto engine = fml::scoped_nsobject<FlutterEngine>{[[FlutterEngine alloc]
initWithName:@"io.flutter"
project:project
allowHeadlessExecution:self.engineAllowHeadlessExecution]};
[_engine.get() createShell:nil libraryURI:nil initialRoute:initialRoute];
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Initialize this FlutterEngine with a `FlutterDartProject`.
*
* If the FlutterDartProject is not specified, the FlutterEngine will attempt to locate
* the project in a default location (the flutter_assets folder in the iOS application
* bundle).
*
* @param labelPrefix The label prefix used to identify threads for this instance. Should
* be unique across FlutterEngine instances, and is used in instrumentation to label
* the threads used by this FlutterEngine.
* @param project The `FlutterDartProject` to run.
*/
- (instancetype)initWithName:(NSString*)labelPrefix project:(nullable FlutterDartProject*)project;

FlutterEngine 初始化时需要传入一个 labelPrefix 参数,作为 FlutterEngine 内部的线程名的前缀,方便和其他线程进行区分。
同时还需要传入一个 FlutterDartProject 对象,其作用后面会再详细说明,此处暂且略过。

FlutterEngine 主要做了两件事情:

  1. 调用 createShell:libraryURI:initialRoute 方法,创建 shell 并进行一系列初始化。
  2. 调用 launchEngine:libraryURI: 方法,启动 engine

createShell

shell 可以理解为将 Dart 执行环境 和 Native 执行环境分离开来的 “壳”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
- (BOOL)createShell:(NSString*)entrypoint
libraryURI:(NSString*)libraryURI
initialRoute:(NSString*)initialRoute {
// ...
// 设置 on_create_platform_view 回调
flutter::Shell::CreateCallback<flutter::PlatformView> on_create_platform_view =
[self](flutter::Shell& shell) {
[self recreatePlatformViewController];
return std::make_unique<flutter::PlatformViewIOS>(
shell, self->_renderingApi, self->_platformViewsController, shell.GetTaskRunners());
};
// 设置 on_create_rasterizer 回调
flutter::Shell::CreateCallback<flutter::Rasterizer> on_create_rasterizer =
[](flutter::Shell& shell) { return std::make_unique<flutter::Rasterizer>(shell); };
// 初始化 TaskRunners
flutter::TaskRunners task_runners(threadLabel.UTF8String, // label
fml::MessageLoop::GetCurrent().GetTaskRunner(), // platform
_threadHost->raster_thread->GetTaskRunner(), // raster
_threadHost->ui_thread->GetTaskRunner(), // ui
_threadHost->io_thread->GetTaskRunner() // io
);

std::unique_ptr<flutter::Shell> shell =
flutter::Shell::Create(std::move(task_runners), // task runners
std::move(platformData), // window data
std::move(settings), // settings
on_create_platform_view, // platform view creation
on_create_rasterizer // rasterzier creation
);

if (shell == nullptr) {
FML_LOG(ERROR) << "Could not start a shell FlutterEngine with entrypoint: "
<< entrypoint.UTF8String;
} else {
[self setupShell:std::move(shell)
withObservatoryPublication:settings.enable_observatory_publication];
}
return _shell != nullptr;
}

- (void)setupShell:(std::unique_ptr<flutter::Shell>)shell
withObservatoryPublication:(BOOL)doesObservatoryPublication {
// ...
[self setupChannels];
_publisher.reset([[FlutterObservatoryPublisher alloc]
initWithEnableObservatoryPublication:doesObservatoryPublication]);
[self maybeSetupPlatformViewChannels];
// ...
}

createShell 这一步主要做了以下几件事情

  • 设置 on_create_platform_view 回调
  • 设置 on_create_rasterizer 回调
  • 初始化 TaskRunners,分配负责 platformrasteruiio
  • 创建 shell,最终会启动 Isolate
  • 创建 FlutterPlatformViewsController
  • 创建 FlutterObservatoryPublisher
  • 创建 PlatformView Channels

launchEngine

launchEngine 方法需要传入一个 entrypoint*,其实就是传入 Dart 代码的执行入口,进而运行 Dart 代码
这一步直接调用创建好的 *shell
RunEngine 方法,基于 FlutterDartProject 配置信息启动 FlutterEngine

1
2
3
4
- (void)launchEngine:(NSString*)entrypoint libraryURI:(NSString*)libraryOrNil {
// Launch the Dart application with the inferred run configuration.
self.shell.RunEngine([_dartProject.get() runConfigurationForEntrypoint:entrypoint
libraryOrNil:libraryOrNil]);

启动 FLutterEngine 之后,FlutterEngine 不断通过 takeScreenshot 方法截取 Dart UI 的界面并进行光栅化,最后绘制 CALayer。

1
2
3
4
5
- (flutter::Rasterizer::Screenshot)takeScreenshot:(flutter::Rasterizer::ScreenshotType)type
asBase64Encoded:(BOOL)base64Encode {
FML_DCHECK(_shell) << "Cannot takeScreenshot without a shell";
return _shell->Screenshot(type, base64Encode);
}

再回头看 FlutterViewdrawLayer:inContext: 方法,Dart UI 的展示流程就很清晰了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- (void)drawLayer:(CALayer*)layer inContext:(CGContextRef)context {
// 创建screenshot,获取渲染数据源,并生成NSData对象
auto screenshot = [_delegate takeScreenshot:flutter::Rasterizer::ScreenshotType::UncompressedImage
asBase64Encoded:NO];
}

## FlutterProject

`FlutterViewController`、`FlutterView`、`FlutterEngine` 这三者介绍地差不多了,最后再来看一下 `FlutterDartProject`。

`FlutterDartProject` 负责管理 Flutter 资源,创建 `FlutterEngine` 实例等。

`FlutterDartProject` 代码量也不多,功能也比较简单,主要提供一个初始化方法,和一系列的 *lookupKeyForAsset* 方法。

```objc
@interface FlutterDartProject : NSObject
/**
* Initializes a Flutter Dart project from a bundle.
*/
- (instancetype)initWithPrecompiledDartBundle:(nullable NSBundle*)bundle NS_DESIGNATED_INITIALIZER;

/**
* Returns the file name for the given asset. If the bundle with the identifier
* "io.flutter.flutter.app" exists, it will try use that bundle; otherwise, it
* will use the main bundle. To specify a different bundle, use
* `-lookupKeyForAsset:asset:fromBundle`.
*
* @param asset The name of the asset. The name can be hierarchical.
* @return the file name to be used for lookup in the main bundle.
*/
+ (NSString*)lookupKeyForAsset:(NSString*)asset;

@end

FlutterDartProject 的代码逻辑虽然简单,但是它的初始化优先级很高。在 FlutterViewControllerFlutterEngine 的初始化方法中,都需要传入 FlutterDartProject 对象。

总结

Dart UI 在 iOS APP 中的展示流程,主要涉及 FlutterViewControllerFlutterViewFlutterEngineFlutterDartProject 等几个类或模块,在 Dart UI 的展示中,它们各自的主要作用为:

  • FlutterView 展示UI
  • FlutterViewController 管理FlutterView等对象
  • FlutterEngine 获取 Dart UI 的界面并进行光栅化
  • FlutterDartProject 管理Flutter资源

本文为 Flutter 学习系列文章的第一篇,主要介绍 iOS APP 的启动流程的前绪部分,包括 iOS APP 工程的目录结构,Flutter Framework 的构建,以及 FlutterViewController 的创建等。

Flutter iOS Framework 源码地址: https://github.com/flutter/engine/tree/master/shell/platform/darwin/ios/framework/Source

工程目录

安装并配置好 Flutter 之后,我们在命名行中执行 flutter create myapp 就能够创建一个 flutter 项目,包括 iOS 和 android 两个平台的代码。 代码目录结构如下:

Flutter 的安装及配置参考 https://flutterchina.club/get-started/install/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
╰─○ ls -al myapp
total 56
drwxr-xr-x 14 chujiayi staff 448 12 19 18:46 .
drwxr-xr-x 8 chujiayi staff 256 12 19 18:45 ..
-rw-r--r-- 1 chujiayi staff 1491 12 19 18:45 .gitignore
drwxr-xr-x 6 chujiayi staff 192 12 19 18:45 .idea
-rw-r--r-- 1 chujiayi staff 305 12 19 18:45 .metadata
-rw-r--r-- 1 chujiayi staff 2477 12 19 18:46 .packages
-rw-r--r-- 1 chujiayi staff 535 12 19 18:45 README.md
drwxr-xr-x 11 chujiayi staff 352 12 19 18:46 android
drwxr-xr-x 6 chujiayi staff 192 12 19 18:45 ios
drwxr-xr-x 3 chujiayi staff 96 12 19 18:45 lib
-rw-r--r-- 1 chujiayi staff 896 12 19 18:45 myapp.iml
-rw-r--r-- 1 chujiayi staff 3279 12 19 18:46 pubspec.lock
-rw-r--r-- 1 chujiayi staff 2657 12 19 18:45 pubspec.yaml
drwxr-xr-x 3 chujiayi staff 96 12 19 18:45 test

其中主要有以下几个目录:

  • ios :存放 iOS 平台的代码的目录
  • android :存放 android 平台的代码的目录
  • lib,存放 dart 代码的目录。

继续看 ios 目录。该目录包含了运行 iOS APP 所需的代码,由 Flutter 自动生成。我们可以直接在 Xcode 中打开 Runner.xcworkspace,就能运行 APP 了。

也可以直接在 myapp 目录执行 flutter run ios

1
2
3
4
5
6
7
8
╰─○ ls -al myapp/ios 
total 0
drwxr-xr-x 6 chujiayi staff 192 12 19 18:45 .
drwxr-xr-x 14 chujiayi staff 448 12 19 18:46 ..
drwxr-xr-x 7 chujiayi staff 224 12 19 18:46 Flutter
drwxr-xr-x 9 chujiayi staff 288 12 19 18:46 Runner
drwxr-xr-x 5 chujiayi staff 160 12 19 18:45 Runner.xcodeproj
drwxr-xr-x 3 chujiayi staff 96 12 19 18:45 Runner.xcworkspace

Flutter动态库生成

打开 Xcode 之后,我们会发现在Xcode工程目录的 Flutter 子目录下有两个红色的文件, App.frameworkFlutter.framework ,说明现在这两个 framework 并不存在。从命令行输出结果来看,也确实没有这两个文件。

1
2
3
4
5
6
7
8
9
╰─○ ls -al myapp/ios/Flutter
total 40
drwxr-xr-x 7 chujiayi staff 224 12 19 19:01 .
drwxr-xr-x 6 chujiayi staff 192 12 19 19:01 ..
-rw-r--r--@ 1 chujiayi staff 794 9 10 2019 AppFrameworkInfo.plist
-rw-r--r--@ 1 chujiayi staff 30 9 10 2019 Debug.xcconfig
-rw-r--r-- 1 chujiayi staff 433 12 19 19:01 Generated.xcconfig
-rw-r--r--@ 1 chujiayi staff 30 9 10 2019 Release.xcconfig
-rwxr-xr-x 1 chujiayi staff 514 12 19 19:01 flutter_export_environment.sh

那么这两个 framework 是什么时候生成的呢?里面又包含了哪些东西呢?
先不用想这么多,我们不妨先尝试在 Xcode 中运行下项目,看看会不会有报错。

一运行,不仅没有任何报错,而且这两个 framework 竟然自动出现了!所以是我们点击运行时,XCode 自动生成了这来给你个 framework ?
我们再次查看Flutter目录,发现确实多了 App.frameworkFlutter.framework

1
2
3
4
5
6
7
8
9
10
11
╰─○ ls -al myapp/ios/Flutter
total 40
drwxr-xr-x 9 chujiayi staff 288 12 19 20:18 .
drwxr-xr-x 7 chujiayi staff 224 12 19 20:25 ..
drwxr-xr-x 5 chujiayi staff 160 12 19 20:18 App.framework
-rw-r--r--@ 1 chujiayi staff 794 9 10 2019 AppFrameworkInfo.plist
-rw-r--r--@ 1 chujiayi staff 30 9 10 2019 Debug.xcconfig
drwxr-xr-x@ 8 chujiayi staff 256 12 19 20:18 Flutter.framework
-rw-r--r-- 1 chujiayi staff 433 12 19 19:01 Generated.xcconfig
-rw-r--r--@ 1 chujiayi staff 30 9 10 2019 Release.xcconfig
-rwxr-xr-x 1 chujiayi staff 514 12 19 19:01 flutter_export_environment.sh

其实很容易想到,一定是最开始执行 flutter create myapp 时,我们的 iOS 工程被做了手脚。
哪里最有可能被做手脚呢?一定是 Build Phase 中添加了自定义的 Run Script

果不其然,我们在 Xcode 中找到了“罪魁祸首” – 一个特殊的 Run Script

1
/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build

这个脚本存在于 Flutter 的安装目录下。代码有点多,我们挑重点部分来看:

脚本完整代码可查看 https://github.com/flutter/flutter/blob/master/packages/flutter_tools/bin/xcode_backend.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Adds the App.framework as an embedded binary and the flutter_assets as
# resources.
EmbedFlutterFrameworks() {
# Embed App.framework from Flutter into the app (after creating the Frameworks directory
# if it doesn't already exist).
local xcode_frameworks_dir="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}"
RunCommand mkdir -p -- "${xcode_frameworks_dir}"
RunCommand rsync -av --delete --filter "- .DS_Store/" "${BUILT_PRODUCTS_DIR}/App.framework" "${xcode_frameworks_dir}"

# Embed the actual Flutter.framework that the Flutter app expects to run against,
# which could be a local build or an arch/type specific build.

# Copy Xcode behavior and don't copy over headers or modules.
RunCommand rsync -av --delete --filter "- .DS_Store/" --filter "- Headers/" --filter "- Modules/" "${BUILT_PRODUCTS_DIR}/Flutter.framework" "${xcode_frameworks_dir}/"
if [[ "$ACTION" != "install" || "$ENABLE_BITCODE" == "NO" ]]; then
# Strip bitcode from the destination unless archiving, or if bitcode is disabled entirely.
RunCommand "${DT_TOOLCHAIN_DIR}"/usr/bin/bitcode_strip "${BUILT_PRODUCTS_DIR}/Flutter.framework/Flutter" -r -o "${xcode_frameworks_dir}/Flutter.framework/Flutter"
fi

# Sign the binaries we moved.
if [[ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]]; then
RunCommand codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${xcode_frameworks_dir}/App.framework/App"
RunCommand codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${xcode_frameworks_dir}/Flutter.framework/Flutter"
fi

AddObservatoryBonjourService
}

上面这部分的代码注释已经很清晰了,主要就是生成 App.frameworkFlutter.framework 并放置到对应目录下。 生成工程项目的时候,Xcode 中已经引用了这两个 framework ,只不过当时它们还不存在;一执行编译,这两个 framework 就会自动生成,也就不会有任何报错了。

其中,Flutter.frameworkFlutter Engine 部分的代码,由 C++ 编译而成。 App.framework (在Release模式下)包含了 dart AOT 编译后的产物)。

这两个库都是动态库,我们可以直接通过 file 命令进行验证。

1
2
╰─○ file myapp/ios/Flutter/App.framework/App 
myapp/ios/Flutter/App.framework/App: Mach-O 64-bit dynamically linked shared library x86_64

总结:Flutter 项目会额外引入两个动态库,包含 Flutter Engine 和 Dart 代码 等。

Flutter动态库加载

在执行 flutter create myapp 创建项目时,Flutter 就会在 Xcode 工程文件中添加这两个 framework 的引用。在 ** Xcode Build Phase** 的 Link Binary with LibrariesEmbed Frameworks 中,我们都能看到这两个库的存在。

1
2
3
4
5
6
7
8
9
10
11
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */,
3B80C3941E831B6300D905FE /* App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */

因此,在 APP 的启动阶段,这两个动态库就会被自动加载。

APP 生命周期处理

继续观察之前创建好的项目,其实基本没有什么代码,主要就有一个 AppDelegate 类,继承自 FlutterAppDelegate

1
2
3
4
5
6
7
8
9
10
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

FlutterAppDelegate 类在 Flutter.framework 中定义。 FlutterAppDelegate 又继续将生命周期回调派发给各个 FlutterPluginAppLifeCycleDelegate,以便各个 Plugin 处理 APP 生命周期回调事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (BOOL)application:(UIApplication*)application
didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
for (NSObject<FlutterApplicationLifeCycleDelegate>* delegate in [_delegates allObjects]) {
if (!delegate) {
continue;
}
if ([delegate respondsToSelector:_cmd]) {
if (![delegate application:application didFinishLaunchingWithOptions:launchOptions]) {
return NO;
}
}
}
return YES;
}

FlutterViewController 和 FlutterView

FlutterAppDelegate 类似乎并没有做什么其他事情,那 Flutter 的页面是怎么展示到屏幕上的呢?
如果我们去看 APP 的页面层级的话,会发现最顶层为一个 FlutterViewController ,其 viewFlutterView

Flutter 创建的 APP 默认使用了 storyboard ,并在 storyboard 中将 rootViewController 设置为 FlutterViewControllerFlutterViewControllerFlutterView 就承载了具体的 Flutter 页面。

FlutterViewController 有两个新增的 designated initializer,其中默认调用的是 initWithProject:nibName:bundle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@interface FlutterViewController : UIViewController <FlutterTextureRegistry, FlutterPluginRegistry>

/**
* Initializes this FlutterViewController with the specified `FlutterEngine`.
*
* The initialized viewcontroller will attach itself to the engine as part of this process.
*
* @param engine The `FlutterEngine` instance to attach to.
* @param nibNameOrNil The NIB name to initialize this UIViewController with.
* @param nibBundleOrNil The NIB bundle.
*/
- (instancetype)initWithEngine:(FlutterEngine*)engine
nibName:(NSString*)nibNameOrNil
bundle:(NSBundle*)nibBundleOrNil NS_DESIGNATED_INITIALIZER;

/**
* Initializes a new FlutterViewController and `FlutterEngine` with the specified
* `FlutterDartProject`.
*
* @param projectOrNil The `FlutterDartProject` to initialize the `FlutterEngine` with.
* @param nibNameOrNil The NIB name to initialize this UIViewController with.
* @param nibBundleOrNil The NIB bundle.
*/
- (instancetype)initWithProject:(FlutterDartProject*)projectOrNil
nibName:(NSString*)nibNameOrNil
bundle:(NSBundle*)nibBundleOrNil NS_DESIGNATED_INITIALIZER;
1
2
3
- (instancetype)initWithNibName:(NSString*)nibNameOrNil bundle:(NSBundle*)nibBundleOrNil {
return [self initWithProject:nil nibName:nil bundle:nil];
}

我们注意到,另一个初始化方法 initWithEngine:nibName:bundle: 支持传入一个 FlutterEngine 参数,这样就能够实现 FlutterEngine 的复用。但是要注意,同一时刻一个 FlutterEngine 最多只能绑定到一个 FlutterViewController 上,否则调用该初始化方法时就会直接报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (instancetype)initWithEngine:(FlutterEngine*)engine
nibName:(nullable NSString*)nibName
bundle:(nullable NSBundle*)nibBundle {
NSAssert(engine != nil, @"Engine is required");
self = [super initWithNibName:nibName bundle:nibBundle];
if (self) {
_viewOpaque = YES;
if (engine.viewController) {
FML_LOG(ERROR) << "The supplied FlutterEngine " << [[engine description] UTF8String]
<< " is already used with FlutterViewController instance "
<< [[engine.viewController description] UTF8String]
<< ". One instance of the FlutterEngine can only be attached to one "
"FlutterViewController at a time. Set FlutterEngine.viewController "
"to nil before attaching it to another FlutterViewController.";
}
// 此处省略 ...
}
return self;
}

总结

文本主要介绍了 Flutter APP 工程目录、iOS Framework 的生成以及 FlutterViewController 的基本代码等,大家应该对 Flutter iOS APP 的构建流程有了基本的了解。

当然我们也遗留了很多问题还没解释清楚,比如 Flutter 页面最终是如何渲染到屏幕上的? Flutter Engine 的作用是什么? iOS 与 android 的启动流程有什么区别? 这些问题都留待后续文章继续探讨。

参考

在常用的高阶函数中,相比于 mapflatMapfilter 这些,reduce 理解起来更晦涩一些,不如前面几个功能明确。

reduce 一词,在英文中的解释为“减少、降低等”,在函数式编程中,我们可以把这个单词理解为“合并、归纳”。也就是说,reduce 的作用是合并结果,把多项变成一项。至于怎么合并,就看传入的转换方法是什么。

部分编程语言也会使用 foldcompress 等表示 reduce ,参考 https://en.wikipedia.org/wiki/Fold_%28higher-order_function%29 。但是主流编程语言中,还是使用 reduce 一词的比较多。

Swift中的 reduce

Swift提供了两个不同的 reduce 方法。

  • reduce(_:_:)
1
2
/// Returns the result of combining the elements of the sequence using the given closure.
@inlinable public func reduce<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result
  • reduce(into:_:)
1
2
/// Returns the result of combining the elements of the sequence using the given closure.
@inlinable public func reduce<Result>(into initialResult: Result, _ updateAccumulatingResult: (inout Result, Element) throws -> ()) rethrows -> Result

两个方法的作用完全一样,都是 Returns the result of combining the elements of the sequence using the given closure ,翻译过来就是 使用传入的closure合并序列并返回 。在使用上两个方法有什么区别呢?

TLDR: 具体作用没区别。只是当返回结果为ArrayDictionaryCoy On Write 类型的数据时,优先使用reduce(into:_:) 以提升性能。

reduce(_:_:)

1
2
3
4
5
6
7
8
9
10
11
12
13
/// - Parameters:
/// - initialResult: The value to use as the initial accumulating value.
/// `initialResult` is passed to `nextPartialResult` the first time the
/// closure is executed.
/// - nextPartialResult: A closure that combines an accumulating value and
/// an element of the sequence into a new accumulating value, to be used
/// in the next call of the `nextPartialResult` closure or returned to
/// the caller.
/// - Returns: The final accumulated value. If the sequence has no elements,
/// the result is `initialResult`.
///
/// - Complexity: O(*n*), where *n* is the length of the sequence.
@inlinable public func reduce<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result

以下面的代码为例,介绍reduce(_:_:)的执行流程。

1
2
3
4
5
let numbers = [1, 2, 3, 4]
let numberSum = numbers.reduce(0, { x, y in
x + y
})
// numberSum == 10
  1. 调用 nextPartialResult,传入的参数为 x: *initialResult0),y:*numbers 数组第一个元素(1),返回相加的结果:1
  2. 继续调用 nextPartialResult,传入的参数为 x: 上一步的返回值,ynumbers 数组下一个元素,返回相加的结果。
  3. numbers 数组遍历完毕后,将最后的结果最为返回值返回。

如果 numbers 数组为空,则 nextPartialResult 不会执行,直接返回 initialResult

reduce(into:_:)

1
2
3
4
5
6
7
8
9
/// - Parameters:
/// - initialResult: The value to use as the initial accumulating value.
/// - updateAccumulatingResult: A closure that updates the accumulating
/// value with an element of the sequence.
/// - Returns: The final accumulated value. If the sequence has no elements,
/// the result is `initialResult`.
///
/// - Complexity: O(*n*), where *n* is the length of the sequence.
@inlinable public func reduce<Result>(into initialResult: Result, _ updateAccumulatingResult: (inout Result, Element) throws -> ()) rethrows -> Result

以下面的代码为例,介绍reduce(into:_:)的执行流程。

1
2
3
4
5
let letters = "abracadabra"
let letterCount = letters.reduce(into: [:]) { counts, letter in
counts[letter, default: 0] += 1
}
// letterCount == ["a": 5, "b": 2, "r": 2, "c": 1, "d": 1]
  1. 调用 updateAccumulatingResult,传入的参数为 counts: *initialResult[:]),letter: *letters 数组第一个元素(a),返回结果:[a: 1]
  2. 继续调用 updateAccumulatingResult ,传入的参数为 counts: 上一步的返回值,letterletters 数组下一个元素,返回结果。
  3. letters 数组遍历完毕后,将最后的结果最为返回值返回。

如果 letters 数组为空,则 updateAccumulatingResult 不会执行,直接返回 initialResult

总结

reduce(_:_:)reduce(into:_:) 除了参数不一样外真没啥区别。不过从方法命名上看,我们也能大概猜到 reduce(into:_:) 会把归并结果会更新到 initialResult ,这应该就是Swift官方推荐当返回结果为ArrayDictionaryCoy On Write 类型的数据时,优先使用reduce(into:_:)的原因。

This method(reduce(into:_:)) is preferred over reduce(_:_:) for efficiency when the result is a copy-on-write type, for example an Array or a Dictionary.

使用reduce实现map、filter等

弄明白 reduce 的原理之后,大家可以发现这是一个很灵活的方法。基本上能用 mapfilter 实现的功能都可以用 reduce 代替。

使用reduce实现map

1
2
3
4
5
6
7
extension Array {
public func mapUsingReduce<T>(_ transform: (Element) throws -> T) rethrows -> [T] {
return try self.reduce(into: []) { (result, element) in
result.append(try transform(element))
}
}
}

使用reduce实现filter

1
2
3
4
5
6
7
extension Array {
public func mapUsingReduce<T>(_ transform: (Element) throws -> T) rethrows -> [T] {
return try self.reduce(into: []) { (result, element) in
result.append(try transform(element))
}
}
}

参考

内存优化一直是客户端性能优化的重要组成部分,内存泄漏又是内存问题的一大罪魁祸首,那么如何高效快速地检测并修复内存泄漏呢?本文介绍一种在开发阶段自动化检测页面(ViewController/View)内存泄漏问题的实践方案。

TLDR

  • 使用 MLeaksFinder 等工具找到内存泄漏对象
  • 使用 FBRetainCycleDetector 获取循环引用链
  • (对于被全局对象引用的场景)获取全局对象引用链

介绍

所谓内存泄漏,就是程序已分配的内存由于某种原因未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
一句话概括,就是无法释放不再使用的内存

在iOS开发中最常遇到的内存泄漏类型有:

  1. 存在循环引用,导致对象无法释放
  2. 被全局对象(如单例)持有,导致对象无法释放
  3. (非ARC管理的对象)没有主动释放

本文主要介绍前两种内存泄漏的检测,第三种内存泄漏问题不在本文的讨论范围内。

目标

  1. 自动检测内存泄漏,及时告警
  2. 自动获取引用链,高效修复

总的来说,就是越自动化越好,信息越全越好。
因此,本文不会介绍如何使用 Xcode/Instrument 手动检测内存泄漏。

内存泄漏检测

本文仅介绍页面级别的内存泄漏检测,包括 ViewController 及其 View/Subviews

检测内存泄漏其实是一个很麻烦的问题。在文章开头的定义中我们知道,内存泄漏指的是无法释放不再使用的内存。那么哪些内存属于不再使用的内存呢?显然,如果没有具体的上下文信息,这个问题是无解的。

所以内存泄漏就没法自动化检测了?
我们可以另辟蹊径。

在一些特定的场景下,我们可以推断出特定的对象属于不再使用的内存对象。比如,当页面退出后,我们有理由认为该页面(ViewController*)以及该页面的 *View 和所有 Subviews 都应该被销毁。因为在页面退出,这些内存对象就没用了。

业界有很多检测页面内存泄漏的解决方案,比较为大家所熟知的就是 MLeaksFinder 了。
一句话概括 MLeaksFinder 的检测原理,就是在页面退出一段时间后检测该页面及相关 View 是否为空,如果不为空则说明可能出现了内存泄漏。具体原理本文就不再赘述了,大家可以自行了解。

接入 MLeaksFinder 后,在退出页面后如果检测到了内存泄漏,我们就可以输出如下信息:

1
[2020-12-5 19:19:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYShareViewController, 0x13d57c850]

引用链获取

现在我们知道出现了内存泄漏,也知道是哪个对象出现了内存泄漏,但是我们并不知道这个泄漏对象到底被谁引用了。也就是说,我们知道东西丢了,但是并不知道小偷是谁。如何抓到罪魁祸首呢?

如果不借助其他工具,我们只能

  • 对着相关代码一行行看
  • 重复出问题的场景,在 XcodeMemory Graph 中定位该对象。

显然,这两种方案都不够优雅,费时费力,还不一定能找到问题。有没有办法自动获取泄漏对象的引用链呢?

循环引用链

FBRetainCycleDetector 是一个循环引用检测工具,主要原理是生成对象的引用关系图,然后进行深度优先遍历,如果发现了环的存在,则说明出现了循环引用。

1
2
3
4
5
FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
// 添加检测对象
[detector addCandidate:leakedObject];
// 检测循环引用
NSSet *result = [detector findRetainCycles];

FBRetainCycleDetector 的最大问题,就是需要先提供待检测对象(candidate),也就是泄漏对象。泄漏对象如何获得呢?MLeaksFinder 已经帮我们找好了!

MLeaksFinder 负责找到泄漏对象,FBRetainCycleDetector 负责获取泄漏对象的循环引用链,完美!

1
2
3
4
[2020-12-5 19:19:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYAPShareViewController,0x13d57c850],retain info: 
-> CJYShareViewController
-> _view -> CJYLayoutView
-> _layoutSubviewsBlock -> __NSMallocBlock__

全局对象引用链

循环引用场景的自动检测问题已经搞定了,被全局对象持有这个问题怎么解决呢?

如果是全局对象持有 ViewController/View ,那么当页面退出时,ViewController/View 无法被释放,MLeaksFinder 就会检测到内存泄漏。但是,此时并不存在 泄漏对象 -> 全局对象 的引用,只有 全局对象 -> 泄漏对象 的引用,因此并没有出现循环引用,无法使用 FBRetainCycleDetector 获取循环引用链。

这个问题的难点在于,我们很容易就能知道泄漏对象引用了哪些对象(向下查找),但是却无法知道 哪些对象引用了泄漏对象(向上查找)。
既然无法直接向上查找,我们就只有一条路可走了:找到所有的全局对象,然后 向下查找 其是否引用了泄漏对象。

获取所有全局对象

怎么找到所有全局对象呢?我们知道全局对象存储在 Mach-O 文件的 __DATA segment __bss section,那就暴力一点,把该section的所有指针都遍历出来吧!

关于 Mach-O 文件格式的详细信息,可参考 https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/MachOOverview.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
+ (NSArray<NSObject *> *)globalObjects {
NSMutableArray<NSObject *> *objectArray = [NSMutableArray array];
uint32_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
const mach_header_t *header = (const mach_header_t*)_dyld_get_image_header(i);

// 截取最后一段作为image_name,只针对CJYDemo进行处理
const char *image_name = strrchr(_dyld_get_image_name(i), '/');
if (image_name) {
image_name = image_name + 1;
}
if (strncmp(image_name, "CJYDemo", 6) != 0) {
continue;
}

// 获取image偏移量
vm_address_t slide = _dyld_get_image_vmaddr_slide(i);
long offset = (long)header + sizeof(mach_header_t);
for (uint32_t i = 0; i < header->ncmds; i++) {
const segment_command_t *segment = (const segment_command_t *)offset;
// 获取__DATA.__bss section的数据,即静态内存分配区
if (segment->cmd != SEGMENT_CMD_TYPE || strncmp(segment->segname, "__DATA", 6) != 0) {
offset += segment->cmdsize;
continue;
}
section_t *section = (section_t *)((char *)segment + sizeof(segment_command_t));
for (uint32_t j = 0; j < segment->nsects; j++) {
if ((strncmp(section->sectname, "__bss", 5) != 0)) {
section = (section_t *)((char *)section + sizeof(section_t));
continue;
}
// 遍历获取所有全局对象
vm_address_t begin = (vm_address_t)section->addr + slide;
vm_size_t size = (vm_size_t)section->size;
vm_size_t end = begin + size;
section = (section_t *)((char *)section + sizeof(section_t));

const uint32_t align_size = sizeof(void *);
if (align_size <= size) {
uint8_t *ptr_addr = (uint8_t *)begin;
for (uint64_t addr = begin; addr < end && ((end - addr) >= align_size); addr += align_size, ptr_addr += align_size) {
vm_address_t *dest_ptr = (vm_address_t *)ptr_addr;
uintptr_t pointee = (uintptr_t)(*dest_ptr);
// 省略判断指针是否指向OC对象的代码
// ...
// [objectArray addObject:(NSObject *)pointee];
}
}
}
offset += segment->cmdsize;
}
// 仅针对CJYDemo image执行一次,执行完直接break
break;
}
return objectArray;
}

注意需要判断指针指向的是否为OC对象,如果不是合法的OC对象则需要过滤掉。此处参考 https://blog.timac.org/2016/1124-testing-if-an-arbitrary-pointer-is-a-valid-objective-c-object/

输出引用链

拿到所有全局对象后,接下来要做的就是找到 哪个全局对象引用了泄漏对象

怎么找呢?生成全局对象的引用关系图,然后进行深度优先遍历,如果发现了泄漏对象的存在,则说明该全局对象引用了泄漏对象。

等等,这不是和 FBRetainCycleDetector 的检测机制差不多吗?有没有办法复用 FBRetainCycleDetector 的检测逻辑呢?
好像不行,因为此时并没有出现循环引用?

秉着不重复造轮子的态度,我决定强行使用 FBRetainCycleDetector 这个轮子。没有循环引用,我就人工造一个循环引用出来!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)checkLeakedObject:(NSObject *)leakedObject withGlobalObjects:(NSArray<NSObject *> *)globalObjects {
// 如果leakedObject被全局对象持有,那么实际不存在循环引用链。这里人工设置associatedObject造成循环引用,以便被detector检测到。
[FBAssociationManager hook];
for (NSObject *obj in globalObjects) {
objc_setAssociatedObject(leakedObject, $(@"qn_apm_fake_%p", obj).UTF8String, obj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

// 开始检测,并过滤无用数据
FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
[detector addCandidate:leakedObject];
NSSet *result = [detector findRetainCycles];

// 此处省略过滤逻辑,因为全局对象本身可能就有循环引用,需要过滤出包含leakedObject的引用链
// filter...

// 移除人工设置的associatedObject
for (NSObject *obj in globalObjects) {
objc_setAssociatedObject(leakedObject, $(@"qn_apm_fake_%p", obj).UTF8String, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[FBAssociationManager unhook];

给泄漏对象 添加上对全局对象的引用 后,如果全局对象也引用了泄漏对象,那自然就出现循环引用了,也就能用 FBRetainCycleDetector 获取到引用链了。

最后再处理下检测结果,将添加的 __associated_object 换成 [Global] 进行输出,结果就非常清晰了。

1
2
3
[2020-12-5 19:31:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYAPShareViewController, 0x13d5733c4],retain info: 
[Global] -> CJYGlobalObject
-> _vc -> CJYShareViewController

总结

本文介绍了如何通过自动化工具进行页面级别的内存泄漏检测,并输出详细的循环引用和全局对象引用信息,方便开发者快速高效地发现并修复内存泄漏问题。

值得注意的是,内存泄漏的自动化检测必然存在False Positive,也就是把不是内存泄漏的场景判定是内存泄漏。因为对象无论是被循环引用还是被全局对象引用,只要符合预期(对象还有用),那么就不应该被判定为内存泄漏。内存泄漏自动检测工具一般都会提供白名单机制,用于忽略不应该被判定为内存泄漏的场景。

iOS 内存泄漏检测工具已经在Github开源,欢迎关注!

用Swift做算法题时,经常遇到输入为String的情况,但是用Swift的String API遍历元素非常麻烦,每次都得Google一下。做这类题目时,我一般会直接把String转成Array,然后就能愉快地用数组下标访问元素了。

1
2
3
4
5
6
7
8
9
10
11
// 直接使用String遍历字符串
for i in 0..<s.count {
let ch = s[s.index(s.startIndex, offsetBy: i)]
// ...
}

// 转换为Array再遍历字符串
let array = Array(string)
for ch in array {
// ...
}

但是转换为Array毕竟会产生额外的内存消耗,身为有追求的程序员,咱必须最求严格要求自己。于是有一天刷到有效的字母异位词这个题目时,我决定直接使用Swift的String API来写。

题目很简单,主要步骤就是遍历字符串,并存储在哈希表中。直接上代码(解法也很朴素😳):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func isAnagram(_ s: String, _ t: String) -> Bool {
guard s.count == t.count else {
return false
}
var map = [Character: Int]()
for i in 0..<s.count {
let ch = s[s.index(s.startIndex, offsetBy: i)]
if map[ch] != nil {
map[ch] = map[ch]! + 1
} else {
map[ch] = 1
}
}
for i in 0..<t.count {
let ch = t[t.index(t.startIndex, offsetBy: i)]
if map[ch] != nil {
map[ch] = map[ch]! - 1
if map[ch] == 0 {
map[ch] = nil
}
} else {
return false
}
}
return map.count == 0
}

一提交,超时了。当输入为特别长(如50000)的字符串时,提示上述代码的运行时间超过限制。
我反复研究了自己写的代码,O(n)的时间复杂度,没啥毛病啊。怀着试一试的心态,我又提交了一次,还是超时。。

实在没办法了,我决定把String转成Array,然后直接基于Array来遍历。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func isAnagram(_ s: String, _ t: String) -> Bool {
guard s.count == t.count else {
return false
}
var map = [Character: Int]()
let sArray = Array(s)
let tArray = Array(t)
for ch in sArray {
if map[ch] != nil {
map[ch] = map[ch]! + 1
} else {
map[ch] = 1
}
}
for ch in tArray {
if map[ch] != nil {
map[ch] = map[ch]! - 1
if map[ch] == 0 {
map[ch] = nil
}
} else {
return false
}
}
return map.count == 0
}

再提交,竟然就通过了😂。

性能测试

难道是LeetCode的问题?我决定用Swift Playground跑个性能测试看看结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class PerformanceTest: XCTestCase {
let string = "..." // 长度为50000的字符串

func testPerformanceOfArray() {
measure {
let _ = solution.isAnagramUsingArrayApi(string, string)
}
}

func testPerformanceOfString() {
measure {
let _ = solution.isAnagramUsingStringApi(string, string)
}
}
}

PerformanceTest.defaultTestSuite.run()
  • 使用Array的性能测试结果:
1
<unknown>:0: Test Case '-[__lldb_expr_20.PerformanceTest testPerformanceOfArray]' measured [Time, seconds] average: 1.709, relative standard deviation: 4.094%, values: [1.792613, 1.672665, 1.666029, 1.608078, 1.619144, 1.832425, 1.732586, 1.706398, 1.772683, 1.683238], performanceMetricID:com.apple.XCTPerformanceMetric_WallClockTime, baselineName: "", baselineAverage: , maxPercentRegression: 10.000%, maxPercentRelativeStandardDeviation: 10.000%, maxRegression: 0.100, maxStandardDeviation: 0.100

平均运行时间为 1.709 s

  • 使用String的性能测试结果:
1
<unknown>:0: Test Case '-[__lldb_expr_28.PerformanceTest testPerformanceOfString]' measured [Time, seconds] average: 33.674, relative standard deviation: 1.500%, values: [32.627662, 33.954215, 34.245659, 34.001036, 33.695257, 34.446044, 33.701735, 33.209314, 33.351506, 33.507718], performanceMetricID:com.apple.XCTPerformanceMetric_WallClockTime, baselineName: "", baselineAverage: , maxPercentRegression: 10.000%, maxPercentRelativeStandardDeviation: 10.000%, maxRegression: 0.100, maxStandardDeviation: 0.100

平均运行时间为 33.674 s

眼前的结果不禁令人陷入沉思,Swift的String API不仅难用,性能也不行,到底是出了什么问题?

Swift的String变迁史

通过阅读Swift的文档,我们知道String其实是一个Collection类型(遵循Collection协议),也就是说,String是可以使用下标访问的。

A Unicode string value that is a collection of characters.
A string is a series of characters, such as “Swift”, that forms a collection. Strings in Swift are Unicode correct and locale insensitive, and are designed to be efficient. The String type bridges with the Objective-C class NSString and offers interoperability with C functions that works with strings.

在Swift语言的历史上,String有三次比较大的改动:

  1. Swift1 版本,遵循Collection协议
  2. Swift2-3 版本,不再遵循Collection协议
  3. Swift4 版本,重新遵循Collection协议

可以看到,Swift Core Team对String该何如实现也是有过争议和动摇的,但是最终还是让遵循Collection协议了。
为什么最终会遵循Collection协议呢?我理解主要有以下两点:

  • 更易于理解,字符串其实就是字符的集合
  • 从设计上,StringCollection可以直接用一套API

关于最终的选型,Swift Core Team有更深入的思考,有兴趣的可以继续阅读https://github.com/apple/swift/blob/main/docs/StringManifesto.md

遵循Collection协议本身对开发者应该是个好事,因为这样更容易理解,一套API就可以搞定所有常用的集合类型。但是为什么String的接口这么复杂,性能也不高呢?

字符串一直都很难

字符串一直是个老大难的问题,Unicode也一直是开发者的噩梦。

上文说过,Swift的String对外表现是字符的集合,其底层实现其实是UTF-8编码单位的集合,每个字符由1到4个UTF-8编码单位组成。也就是说,String的每一个元素的长度是不一定相等的,我们无法直接使用Int类型的数字下标去访问集合中的字符,这也是StringArray等其他集合类型的最大区别。

正是由于这个原因,Swift不得不放弃Int类型的下标,在内部通过自定义的Index类型记录对应元素的偏移量,并提供通过Index进行元素访问的接口。

1
2
3
/// A position of a character or code unit in a string.
@frozen public struct Index {
}
1
2
3
4
// 读取string的第2个字符
let string = "Swift"
let index = string.index(string.startIndex, offsetBy: 1)
string[index]

对性能的影响

无论是Int类型的下标还是Index类型的下标,作为集合类型,通过下标访问元素的时间复杂度都应该是 O(1),那为什么Swift中方法String的元素会比访问Array的元素性能差这么多呢?

问题就出在Index的计算上。也就是

1
let index = string.index(string.startIndex, offsetBy: 1)

对于每个 index ,都需要根据当前 string 的实际字符情况计算真实的偏移量,不难看出,这一步的时间复杂度应该是 O(n)
而对于数组来说,不需要计算这个 index ,自然性能会更高。

回到最开始的算法题,如下代码中,遍历获取字符串 s 中的字符时,每次都会重新计算 index,导致运行时间过长。

1
2
3
4
5
6
7
8
9
10
11
12
func isAnagram(_ s: String, _ t: String) -> Bool {
// ...
for i in 0..<s.count {
let ch = s[s.index(s.startIndex, offsetBy: i)]
if map[ch] != nil {
map[ch] = map[ch]! + 1
} else {
map[ch] = 1
}
}
// ...
}

那如果不要每次都重新计算 index ,而是保存当前的 index 结果,每次只需继续向后偏移 1 位,会不会提升性能呢?
我们将上面的代码稍作修改,重新跑一边性能测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func isAnagram(_ s: String, _ t: String) -> Bool {
// ...
var index = s.startIndex
for _ in 0..<s.count {
let ch = s[index]
index = s.index(index, offsetBy: 1)
if map[ch] != nil {
map[ch] = map[ch]! + 1
} else {
map[ch] = 1
}
}
// ...
}
1
<unknown>:0: Test Case '-[__lldb_expr_38.PerformanceTest testPerformanceOfString]' measured [Time, seconds] average: 6.137, relative standard deviation: 6.898%, values: [5.295313, 6.041929, 7.026869, 6.305301, 6.466630, 5.881635, 5.932458, 5.979560, 6.214758, 6.226155], performanceMetricID:com.apple.XCTPerformanceMetric_WallClockTime, baselineName: "", baselineAverage: , maxPercentRegression: 10.000%, maxPercentRelativeStandardDeviation: 10.000%, maxRegression: 0.100, maxStandardDeviation: 0.100

果然,平均运行时间降低到了 6.137s,虽然还是比用Array慢,不过已经快了很多!验证结果符合预期✌️。

看来String内部并没有对index的计算结果做缓存,不过从语言设计上看,确实不该在内部做这个缓存。

总结

Swift中的字符串历经多个版本的变迁,依然令人非常头疼。其之所以使用起来麻烦,是因为内部字符长度长度不固定,无法直接使用普通的数字下标进行索引,必须维护内部特殊的Index。而Index需要经过计算得出,也就带来了访问字符串元素时的性能问题。

但是无论如何,Swift一直在对String进行持续优化,包括提供更方便的接口(如Offset-Based Access)等。从Swift的设计理念上来说,正确性比易用性更重要。为了保证代码不出问题,稍微麻烦一点也是可以接受的。

String还有很多有意思的地方值得深入学习,接下来的文章继续探讨吧。

参考

最近主要在学习SwiftSwiftUI,准备搞一个全栈使用Swift的完整项目,在实际工程中进行深入探索。同时我也一直想做一个完整的全栈开发项目,提升下姿势水平。

目前的初步想法是做一个电影相关的APP,主要涉及的技术栈有:

  • 客户端
  • Web端
  • Server
  • 数据库

客户端 & Web端

因为最主要的目标是学习SwiftUI,所以iOS客户端我会使用SwiftUI来写,这样就直接实现了苹果的“跨平台”,支持所有Apple Platform(iOS/iPadOS/watchOS/macOS)。

Android端优先级比较低,毕竟不如iOS熟悉,后面可以考虑用 Weex 等跨平台方案来实现,正好横向对比下 真实的跨平台(iOS/Android/Web)和 虚假的跨平台(iOS/iPadOS/watchOS/macOS)到底谁更有前途。(好像不用对比也能知道💔)

正好 Weex 也支持Web端,一套代码搞定Android和Web,也能顺便学习下JS大法和VUE

那么问题来了:

为啥iOS不也用Weex来写呢?

  • 回答0:身为iOS客户端开发,要对Swift语言有信仰!而且用SwiftUI来开发,同时能支持所有Apple Platform,不也挺香吗。

  • 回答1:反正是个学习的项目,怎么折腾怎么来😏。

为啥不用Java或者Kotlin来写Android呢?

  • 有了Swift,谁还用JavaKotlin啊😄。

Server & 数据库

后台用Server-Side Swift来写,参考Vapor
数据抓取可能需要用到Python。
数据库继续使用MySQL。
服务器继续使用腾讯云。

之前写后台都是用的非常成熟的 LNMP(Linux、Nginx、MySQL、PHP/Python/Perl)方案,而且都是些很简单的项目,比如APNS push服务、Bitwarden服务等,所以这次后台和数据库的部分挑战会比较大。不过好在服务器已经有了,而且能够直接使用Swift来写,好像也不会太折腾。

可能存在的坑就是Server-Side Swift还不成熟,配套的社区和插件会比较弱,不过作为学习项目,有啥用啥呗。

计划

啊,要做的东西太多了!还是一步步来吧,不能好高骛远。

  1. 使用SwiftUI完成客户端基本UI
  2. 配置Swift服务端开发环境,跑通流程
  3. 使用Python抓取真实数据并存储
  4. 实现接入层逻辑,完成客户端和Server的交互
  5. 使用 Weex 编写Android端和Web端

千里执行始于足下,无论是milestone还是stone砸出的大坑,都走着瞧吧!

上一篇文章介绍了Swizzle的错误姿势,错误地使用Swizzle很容易引发一系列隐蔽的问题,那么我们该如何正确且优雅地进行Swizzle呢?

在错误的Swizzle方案中,为了修改方法的实现,我们会新增一个方法(jy_viewDidLoad),然后和原始方法(viewDidLoad)进行交换。这个新增的方法其实没有什么作用,只是为了提供一个新的实现而已。

但是这个新增的方法却给代码带来了一系列的问题,比如上一篇文章提到的_cmd错误。那么能不能不新增这个方法呢?

当然是可以的。

方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#import <objc/runtime.h>
#import <objc/message.h>

IMP class_swizzleSelector(Class clazz, SEL selector, IMP newImplementation)
{
// If the method does not exist for this class, do nothing
Method method = class_getInstanceMethod(clazz, selector);
if (! method) {
return NULL;
}

// Make sure the class implements the method. If this is not the case, inject an implementation, calling 'super'
const char *types = method_getTypeEncoding(method);
class_addMethod(clazz, selector, imp_implementationWithBlock(^(__unsafe_unretained id self, va_list argp) {
struct objc_super super = {
.receiver = self,
.super_class = class_getSuperclass(clazz)
};

id (*objc_msgSendSuper_typed)(struct objc_super *, SEL, va_list) = (void *)&objc_msgSendSuper;
return objc_msgSendSuper_typed(&super, selector, argp);
}), types);

// Can now safely swizzle
return class_replaceMethod(clazz, selector, newImplementation, types);
}

通过直接修改原有实现的方式,我们就可以做到不新增方法的Swizzle。

好像看着也不复杂?但实际情况并没有这么简单。虽然OC中大多数的方法调用都是使用objc_msgSend,但是毕竟是由例外的。当方法返回值是较大的结构体时,此时返回值无法直接放入寄存器中,编译器会转而使用objc_msgSend_stret方法,将返回值直接放入栈中。
这种情况下,上面的方案就不适用了,我们需要继续针对objc_msgSend_stret方法进行处理。

objc_msgSend_stret

话不多说,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
IMP class_swizzleSelector_stret(Class clazz, SEL selector, IMP newImplementation)
{
// If the method does not exist for this class, do nothing
Method method = class_getInstanceMethod(clazz, selector);
if (! method) {
return NULL;
}

// Make sure the class implements the method. If this is not the case, inject an implementation, only calling 'super'
const char *types = method_getTypeEncoding(method);
class_addMethod(clazz, selector, imp_implementationWithBlock(^(__unsafe_unretained id self, va_list argp) {
struct objc_super super = {
.receiver = self,
.super_class = class_getSuperclass(clazz)
};

// Sufficiently large struct
typedef struct LargeStruct_ {
char dummy[16];
} LargeStruct;

// Cast the call to objc_msgSendSuper_stret appropriately
LargeStruct (*objc_msgSendSuper_stret_typed)(struct objc_super *, SEL, va_list) = (void *)&objc_msgSendSuper_stret;
return objc_msgSendSuper_stret_typed(&super, selector, argp);
}), types);

// Can now safely swizzle
return class_replaceMethod(clazz, selector, newImplementation, types);
}

这种方案虽然比直接新增方法并替换麻烦了一些,但是可以做到不新增方法实现Swizzle,也就自然不会有_cmd错误的问题。

这才是Swizzle的正确姿势!

参考

建议先阅读iOS单元测试数据统计iOS代码覆盖率统计

本文涉及的所有脚本均托管在GitHub项目 UnitTestParser 中。
Demo工程:UnitTestDemo

简介

之前文章介绍的项目代码覆盖率统计增量代码覆盖率统计都是基于这个那个项目维度,统计项目整体的代码覆盖率和增量覆盖率的。有时候我们想要知道项目中每个成员提交的代码覆盖率数据,辅助我们去评估大家提交的代码质量如何,同时也激励大家写出更高质量的单元测试。

个人代码覆盖率

那么如何统计项目中所有代码提交者(committer)的代码覆盖率情况呢?首先我们得知道每一个committer到底提交了那些代码(精确到代码行),然后在此基础上确认这些代码行是否覆盖,最后计算得到代码覆盖率。

代码行负责人

我们知道在Xcode中点击代码编辑器右上角的 Author 按钮,就能够看到当前文件每一行代码的“负责人”,也就是谁最后增新增或者修改了这一行。这个功能是怎么实现的呢?其实就是基于git blame命令。

git-blame - Show what revision and author last modified each line of a file

(详细用法可以在命令行中输入git blame查看,或者参考官方文档。)

比如在Demo项目目录下命令行中执行:

1
╰─± git blame -c --date=short Demo/CDataUtil.h

就能看到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
9caa7b6b        (  jerrychu     2020-03-29      1)//
9caa7b6b ( jerrychu 2020-03-29 2)// CDataUtil.h
9caa7b6b ( jerrychu 2020-03-29 3)// Demo
9caa7b6b ( jerrychu 2020-03-29 4)//
9caa7b6b ( jerrychu 2020-03-29 5)// Created by JerryChu on 2019/12/15.
9caa7b6b ( jerrychu 2020-03-29 6)// Copyright © 2019 Chu. All rights reserved.
9caa7b6b ( jerrychu 2020-03-29 7)//
9caa7b6b ( jerrychu 2020-03-29 8)
9caa7b6b ( jerrychu 2020-03-29 9)#import <Foundation/Foundation.h>
9caa7b6b ( jerrychu 2020-03-29 10)
9caa7b6b ( jerrychu 2020-03-29 11)NS_ASSUME_NONNULL_BEGIN
9caa7b6b ( jerrychu 2020-03-29 12)
9caa7b6b ( jerrychu 2020-03-29 13)@interface CDataUtil : NSObject
9caa7b6b ( jerrychu 2020-03-29 14)
9caa7b6b ( jerrychu 2020-03-29 15)/// 将数字转化为字符串
9caa7b6b ( jerrychu 2020-03-29 16)/// @discussion 大于等于10万时,展示xx万,不带小数点
9caa7b6b ( jerrychu 2020-03-29 17)/// @discussion 大于等于1万时,展示1.x万,保留一位小数点
9caa7b6b ( jerrychu 2020-03-29 18)/// @discussion 低于1万时,展示实际数字
9caa7b6b ( jerrychu 2020-03-29 19)/// @param count 数字
9caa7b6b ( jerrychu 2020-03-29 20)+ (NSString *)descForCount:(NSInteger)count;
9caa7b6b ( jerrychu 2020-03-29 21)
9caa7b6b ( jerrychu 2020-03-29 22)/// 将数字转化为字符串
9caa7b6b ( jerrychu 2020-03-29 23)/// @discussion 大于等于10万时,展示xx万,不带小数点
9caa7b6b ( jerrychu 2020-03-29 24)/// @discussion 大于等于1万时,展示1.x万,保留一位小数点
9caa7b6b ( jerrychu 2020-03-29 25)/// @discussion 大于`countThreshold`时,展示实际数字
9caa7b6b ( jerrychu 2020-03-29 26)/// @discussion 小于等于`countThreshold`时,不展示
9caa7b6b ( jerrychu 2020-03-29 27)/// @param count 数字
9caa7b6b ( jerrychu 2020-03-29 28)+ (NSString *)descForCount2:(NSInteger)count;
9caa7b6b ( jerrychu 2020-03-29 29)
7e08d03f (jerrychu(褚佳义) 2020-05-01 30)// 将threshold作为参数传入,避免内部产生依赖
7e08d03f (jerrychu(褚佳义) 2020-05-01 31)+ (NSString *)descForCount2:(NSInteger)count withThreshold:(NSInteger)threshold;
7e08d03f (jerrychu(褚佳义) 2020-05-01 32)
9caa7b6b ( jerrychu 2020-03-29 33)@end
9caa7b6b ( jerrychu 2020-03-29 34)
9caa7b6b ( jerrychu 2020-03-29 35)NS_ASSUME_NONNULL_END

既然通过git blame命令就能找到项目中的每一行的代码负责人,那接下来的具体步骤就很清晰了。

具体步骤

  1. 遍历所有文件,获取每个committer负责所有代码行。
  2. 遍历其负责的代码行数据,确定代码行是否被覆盖。
  3. 对每个committer分别统计代码覆盖率。

第一步通过对git blame命令的输出结果进行解析和统计,就可以得到。
第二步可以直接参考之前的增量代码覆盖率统计文章,里面详细得介绍了如何判断代码行是否被覆盖,原理是一样的。
第三步就是单纯的计算了,没啥可说的,写出来主要是为了分三步走。

脚本实现

UnitTestParser项目提供了 userCov.rb 脚本来实现个人代码覆盖率的解析和输出。

例如执行:

1
╰─± ruby ../UnitTestParser/userCov.rb --xcresult-path=test.xcresult --output-file=userCov.html --proj-dir=./

则会生成如下的html

1
2
3
代码覆盖率(from 1970-01-01 to 2020-08-09)
开发者 增加/修改代码行数 覆盖代码行数 覆盖率
jerrychu 154 130 0.84

注意事项

  1. 脚本中使用的 test.xcresult 文件为我本地生成的结果文件,大家使用Demo项目测试该命令之前,需要先在自己机器上重新生成一份 test.xcresult 文件,否则会由于路径不匹配导致无法获取结果数据。

    如何生成 test.xcresult 文件呢?之前的文章都已经提到过,这里再复习一遍。

    1
    2
    # for xcodeproj
    xcodebuild test -project Demo.xcodeproj -scheme Demo -derivedDataPath "${BUILD_DIR}/" -destination "${SIMULATOR_PLATFORM}" -resultBundlePath "${XCRESULT_PATH}" -resultBundleVersion 3
  2. 脚本中需要设置开发人员名单,不在名单内的数据将不会被统计进去。

    1
    2
    3

    # 要统计的开发者列表
    $developer_list = ["jerrychu"]

个人新增代码覆盖率

如果详细看了 userCov.rb 脚本的话,大家会发现脚本执行参数中有一个 begin_date 选项,用于指定从什么时间点开始统计。

为什么要加这样一个参数呢?我们在统计每个committer的代码覆盖率时,更希望看到最近一段时间(比如当前版本的开始时间)内每个committer提交的代码的覆盖率情况。而git blame会把项目中每一个代码行都展示出来,但是其中很多是历史代码,参考意义不大。

附:git blame 也提供一些参数用来设置时间区间或commit区间,但是并不符合我们这里的统计需求,有兴趣的可以在命令行中执行git help blame看下。

设置 begin_date 选项之后,脚本里在解析git blame结果时,就会将这个时间点之前的代码行都过滤掉,只统计这个时间点之后新增或修改的代码行。在实际应用中,我们可以把这个参数设置为版本开始的时间,用来统计从版本开始到现在,大家提交代码的覆盖率情况。

1
2
# 根据git blame统计begin_date时间之后每个人修改过的文件及行数
def user_files_map(proj_dir, begin_date)

在执行脚本时增加 begin-date 参数即可统计到该时间点之后的committer新增代码覆盖率情况。

1
╰─± ruby ../UnitTestParser/userCov.rb --xcresult-path=test.xcresult --proj-dir=./ --output-file=userCov.html --begin-date="2019-10-18"

总结

个人代码覆盖率的统计对提升每个项目成员的单元测试水平和整体单元测试水平都有重要的作用。
UnitTestParser项目提供的脚本可以快速准确地解析出项目中每个人的代码覆盖率,以及每个人的增量代码覆盖率,并且可以做到自动化统计。

常见Swizzle方案

在OC里如何实现Swizzle?相信大家经过一番Google之后很容易就能实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
BOOL JYReplaceMethodWithBlock(Class c, SEL origSEL, SEL newSEL, id block) {

Method origMethod = class_getInstanceMethod(c, origSEL);

// 添加newSEL及对应的实现
IMP newIMP = imp_implementationWithBlock(block);
if (!class_addMethod(c, newSEL, newIMP, method_getTypeEncoding(origMethod))) {
return NO;
}

Method newMethod = class_getInstanceMethod(c, newSEL);

if (class_addMethod(c, origSEL, newIMP, method_getTypeEncoding(origMethod))) {
// 没有origSEL,添加origSEL,使用新实现
// 同时将newSEL替换为旧实现
class_replaceMethod(c, newSEL, method_getImplementation(origMethod), method_getTypeEncoding(origMethod));
} else {
// 已经有origSEL,直接交换实现
method_exchangeImplementations(origMethod, newMethod);
}

return YES;
}

上面就是一个初步的Swizzle方案,重点在最后一部分:

1
2
3
4
5
6
7
8
if (class_addMethod(c, origSEL, newIMP, method_getTypeEncoding(origMethod))) {
// 没有origSEL,添加origSEL,使用新实现
// 同时将newSEL替换为旧实现
class_replaceMethod(c, newSEL, method_getImplementation(origMethod), method_getTypeEncoding(origMethod));
} else {
// 已经有origSEL,直接交换实现
method_exchangeImplementations(origMethod, newMethod);
}

这里主要做的事情就是将 origSELnewSEL 的实现进行交换。交换之后,在调用 origSEL 方法时,就会走到我们的自定义实现,也就完成了Swizzle。

比如我们想要Swizzle掉 ViewController 类的 viewDidLoad 方法,直接添加下面的代码即可。

1
2
3
4
SEL newSEL = NSSelectorFromString(@"jy_viewDidLoad");
JYReplaceMethodWithBlock([ViewController class], @selector(viewDidLoad), newSEL, ^(ViewController *_self){
NSLog(@"swizzled");
});

添加之后,ViewController 类原始的 viewDidLoad 方法就会被新的 jy_viewDidLoad 方法替换掉。因此在调用 viewDidLoad 方法时,实际执行的内容已经变成自定义的是实现了,即

1
NSLog(@"swizzled");

原始方法调用

如果我们只是想插入一段自定义的代码,执行完之后还是想走原始的实现怎么办呢?我们先来看下现在的SELIMP的对应关系。

1
2
viewDidLoad --> 新的实现
jy_viewDidLoad --> 原始viewDidLoad的实现

可以看到,现在 jy_viewDidLoad 对应的就是原始的实现,直接调用该方法就行了。

1
2
3
4
5
SEL newSEL = NSSelectorFromString(@"jy_viewDidLoad");
JYReplaceMethodWithBlock([ViewController class], @selector(viewDidLoad), newSEL, ^(ViewController *_self){
NSLog(@"swizzled");
((void (*)(id, SEL))objc_msgSend)(_self, newSEL);
});

这样就实现了插入自定义代码后继续执行原始逻辑。

非常简单是不是?一般情况下我们使用上面的Swizzle方案不会遇到什么问题,但是它实际是有漏洞的,可能会造成令人崩溃的bug。

Swizzle带来的问题

我们在ViewController类的 viewDidLoad 方法中加一句log,看下当前的 _cmd

1
2
3
4
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"%@", NSStringFromSelector(_cmd));
}

执行之后,输出如下:

1
2020-06-21 19:36:44:675767+8000 Demo[14146:9871258] jy_viewDidLoad

怎么回事?我们明明是在 viewDidLoad 方法中打印的 _cmd,怎么就变成了 jy_viewDidLoad 呢?

为了解释这个问题,我们再复习一下Swizzle代码以及SELIMP的对应关系:

1
2
3
4
5
SEL newSEL = NSSelectorFromString(@"jy_viewDidLoad");
JYReplaceMethodWithBlock([ViewController class], @selector(viewDidLoad), newSEL, ^(ViewController *_self){
NSLog(@"swizzled");
((void (*)(id, SEL))objc_msgSend)(_self, newSEL);
});
1
2
viewDidLoad --> 新的实现
jy_viewDidLoad --> 原始viewDidLoad的实现

看到什么了?实际调用 viewDidLoad 方法时,会走到我们自定义的实现,也就是 NSLog(@"swizzled"); 那段代码,在NSLog之后我们又调用了原始的实现,因为 jy_viewDidLoad 对应的原始的实现,所以调用代码为 ((void (*)(id, SEL))objc_msgSend)(_self, newSEL);。 所以在执行原始实现时,_cmd 变成 jy_viewDidLoad

在本文被的例子中,这个问题可能并不严重,因为并没有基于 _cmd 做任何的逻辑。但是在实际场景中,很有可能有方法使用了 _cmd,比如UIKit中的touch转发:

1
2
3
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
forwardTouchMethod(self, _cmd, touches, event);
}

如果我们Swizzle了 touchesMoved:withEvent: 方法,由于 _cmd 发生了变化,实际运行时 forwardTouchMethod 方法内部会找不到这个方法,导致出现crash。

其他问题

这种Swizzle方法除了会改变 _cmd 之外,也会有一些其他问题,比如同时Swizzle了父类和子类的方法,会导致方法的执行不会按照继承关系走,进而出现一系列奇怪的问题。具体可以参考 http://defagos.github.io/yet_another_article_about_method_swizzling/,本文就不再赘述。

更优雅的Swizzle方案

那么该怎么实现Swizzle才不会有上面的问题呢?Swizzle的正确姿势究竟是啥样的?且待下文分解。
(上面的文章链接中实际已经有答案了,着急的可以直接看~)

参考

建议先阅读iOS单元测试数据统计iOS代码覆盖率统计

本文涉及的所有脚本均托管在GitHub项目 UnitTestParser 中。
Demo工程:UnitTestDemo

简介

增量代码覆盖率指的就是新增代码的代码覆盖率,也就是新增代码中有多少代码被单元测试覆盖了。我们可以通过如下公式计算增量代码覆盖率

1
deltaCov = coveredDeltaLineCount / deltaLineCount

其中,deltaLineCount 指的是新增代码中的有效代码行数coveredDeltaLineCount 指的是新增代码的有效代码行中,被单元测试覆盖的行数。用后者除以前者,就得到了本次新增代码的覆盖率,也就是增量代码覆盖率。

使用场景

增量代码覆盖率可以保证增量代码的单元测试水平,从而保证整体单元测试水平不断提升。一般情况下,我们会在如下几个场景统计增量代码覆盖率:

  • 有新的 PR(Pull Request)或 MR(Merge Request)时,检测本次PR/MR的增量代码覆盖率,决定是否合入。
  • 检测不同版本之间的增量代码覆盖率,评估每个版本的增量代码单元测试水平。
  • 定时检测每天的增量代码覆盖率,保证代码单元测试水平不断提升。

增量代码覆盖率可以很好地体现新增代码的单元测试水平,有了增量代码覆盖率数据支撑之后,我们才能站在更高维度上把控项目代码的整体单元测试水平,提升代码覆盖率。

增量覆盖率计算

我们再回到增量代码覆盖率的计算公式:

1
deltaCov = coveredDeltaLineCount / deltaLineCount

我们已经知道,deltaLineCount 指的是新增代码中的有效代码行数,coveredDeltaLineCount 指的是新增代码的有效代码行中,被单元测试覆盖的行数。

所以如果想计算增量代码覆盖率,我们需要分三步走:

  1. 找到新增了哪些文件的那些代码行
  2. 获取单个文件的代码覆盖率报告,确定每一行的覆盖情况
  3. 确定新增的代码行是否被单元测试覆盖

下面就按这3步来各个击破。

1. 新增代码统计

怎么去找到本次提交/MR修改了哪些文件的那些代码行呢?

如果大家看过了获取git增量代码数据这篇文章的话,相信这不会是个问题。通过解析git diff的数据,我们就能够获取到
增量代码文件以及对应的代码行。

2. 单个文件覆盖率报告

通过新增代码统计获取到所有新增的文件之后,我们需要对每个文件进行单独的处理,确定这个文件的每一行是否被覆盖。因此,我们必须能够获取单个文件的代码覆盖率报告。

UnitTestDemo项目为例,使用如下命令就可以从 .xcresult 文件中获取到单个文件的代码覆盖率情况,其中包括了每一个代码行被单元测试执行的次数。

1
2
# /path/to/file 为执行单元测试时文件的绝对地址
xcrun xccov view --archive --file /path/to/file /path/to/xcresult_file

上面命令的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
 1: *
2: *
3: *
4: *
5: *
6: *
7: *
8: *
9: *
10: *
11: *
12: *
13: *
14: *
15: *
16: *
17: *
18: *
19: 10
20: 10
21: 10 [
(24, 0, 1)
]
22: 1
23: 9 [
(1, 5, 1)
(30, 0, 2)
]
24: 2
25: 7 [
(1, 5, 2)
]
26: 7
27: 7 [
(6, 0, 10)
]
28: 10
29: 10
30: *
31: *
32: *
33: *
34: *
35: *
36: *
37: 1
38: 1
39: 1 [
(24, 0, 0)
]
40: 0
41: 1 [
(1, 5, 0)
(30, 0, 0)
]
42: 0
43: 1 [
(1, 5, 0)
]
44: 1
45: 1 [
(12, 0, 0)
]
46: 0
47: 0 [
(6, 0, 1)
]
48: 1
49: 1
50: *
51: *
52: *
53: 0
54: 0
55: 0

如果输出报错,先通过xcrun xccov view --archive --file-list test.xcresult命令查看填写的文件路径是否正确。

左侧的索引就是文件的代码行索引,右侧的数字表示的是该行在单元测试中被执行的次数,其中 * 表示该行不是可执行代码行,比如空白行等,在进行覆盖率计算时,这些行不会被当成有效代码行,也就不会被统计进去。

相信看到这里大家就知道增量覆盖率计算公式中的有效代码行是什么意思了。

3. 确定新增代码行是否被覆盖

通过上面的两步,我们就拿到了本次修改的文件及代码行,及每一个代码行是否被单元测试覆盖。接下来我们只需要针对每个文件逐行去解析即可。统计规则如下:

  • 新增可执行代码行:行索引后面不为*
  • 被覆盖的新增可执行代码行:行索引后面不为*,且数字大于0

UnitTestParser项目提供了 deltaCov.rb 脚本来实现增量代码覆盖率的解析和输出。

例如执行:

1
╰─± ruby deltaCov.rb --xcresult-path=path/to/xcresult_file --proj-dir=./ --diff-file=path/to/diff_file --output-file=deltaCov.txt

则会输出:

1
2
3
新增代码覆盖率:1.0
新增可执行代码行数:11
被覆盖的新增可执行代码行数:11

如果输出报错,先通过xcrun xccov view --archive --file-list test.xcresult命令查看填写的文件路径是否正确。

同时这些数据也会被写入到 deltaCov.txt 文件中,便于其他工具读取。

1
2
3
4
╰─± cat deltaCov.txt
1.0
11
11

总结

UnitTestParser项目提供的脚本可以快速准确地解析出增量代码行数和增量代码覆盖行数,进而计算出增量代码覆盖率。

为了统计增量代码覆盖率,首先要获取增量代码,本文介绍如何快速准确地获取增量代码数据。

获取代码diff

git diff 输出分析

相信大家都会使用git diff命令获取代码diff,比如在UnitTestDemo项目中执行:

1
git diff e53b8299 a25e1cc3

将会获得如下的输出:

diff结果

其中,-表示删除的行,+表示新增的行(修改其实就是删除+新增);@@ -30,8 +30,8 @@ 表示删除了原先从第30行开始的8行,新增了从第30行开始的8行。

从上面的diff结果中可以获取到如下信息:

  • targetCoverage.md 文件发生了变更
  • targetCoverage.md 从第30行到第37行,是本次变更的代码行。

diff数据优化

但是 targetCoverage.md 文件只修改了4行代码(上图的红色/绿色部分),为什么diff结果说修改了8行呢?

实际上,出于对效率的考虑(同时也考虑了merge的准确性等),git在做diff时并不是逐行做比较的,而是默认以3行作为一个对比单元,只要对比单元内有数据发生了变更,就认为这个单元是发生了变更的。所以我们看到虽然只修改了4行,git diff的结果却说有8行发生了变更。至于为什么在这次diff下是8行,大家可以再思考一下。

虽然git diff的这个默认行为不会影响到diff的展示(我们可以看到上图红色/绿色部分确实是4行),但是却会对我们统计新增/修改代码行造成困扰。比如这次只修改了4行,但是git diff说有8行变更,导致统计到的数据偏大

如何解决这个问题呢?需要进一步对git diff进行深入研究。

git diff 的帮助文档中可以看到一个有意思的参数:

1
2
3
-U<n>
--unified=<n>
Generate diffs with <n> lines of context instead of the usual three. Implies --patch. Implies -p.

也就是说,我们可以通过设置 unified 的值,来修改 git diff 的默认行为。尝试再命令行中执行:

1
git diff e53b8299 a25e1cc3 --unified=0

将会获得如下的输出:

diff结果

可以看到,加上--unified=0之后,git diff的新增/修改行数统计就是准确的了。

解析diff数据

拿到准确的diff数据之后,我们还需要把git diff输出的数据转化为代码可以读取的数据。对于增量代码统计,我们希望获取到如下数据:

  • 所有变更文件的文件名
  • 每个变更文件对应的的所有变更代码行

其实git diff输出的结果中已经包含了这两份数据,我们要做的就是从输出的结果中解析出这些数据。

本项目提供 diffParser.rb 用于从git diff的输出结果中解析出最终需要的数据。

在命令行中执行

1
2
3
git diff e53b8299 a25e1cc3 --unified=0 > a.diff

ruby utils/diffParser.rb --diff-file=a.diff

就会获得如下的数据

1
{"docs/targetCoverage.md"=>[33, 34, 35, 36]}

其中,key 为所有发生变更的文件名,value 为该文件对应的变更代码行(新增/修改的代码行)。

总结

通过使用git diff命令,并对输出结果进行自定义的解析,我们就可以很容易地获取到新增代码数据了。下一篇文章会继续介绍如何在已经有了增量代码数据的基础上仅一步统计增量代码覆盖率。

建议先阅读iOS单元测试自动化 – 单元测试数据统计

本文涉及的所有脚本均托管在GitHub项目 UnitTestParser 中。
Demo工程:UnitTestDemo

统计整体代码覆盖率以及各个target的代码覆盖率数据。

解析代码覆盖率文件

使用苹果官方提供的命令行工具xccov即可完成代码覆盖率的解析,并且可以获取到整体的代码覆盖率及各个模块的代码覆盖率。

1
xcrun xccov view --report --json #{xcresult_path} > #{json_path}

拿到json文件后,就可以通过解析json文件来获取代码覆盖率。
UnitTestParser项目提供 target.rb 来解析整体代码覆盖率和分模块的代码覆盖率。

1
2
# 传入文件为使用`xccov`解析之后的json文件
ruby targetCoverage.rb --cov-json-path=path/to/json_file --output-file=path/to/output_file

例如执行:

1
╰─± ruby targetCoverage.rb --cov-json-path=result.json --output-file=result.html

我们就可以得到如下的结果:

1
2
3
4
target	        可执行代码行数 覆盖代码行数  代码覆盖率
All 424 313 73.8%
DemoTests.xctest 223 217 97.3%
Demo.app 201 96 47.7%

说明

苹果在Xcode11版本中对单元测试结果文件(.xcresult*文件)及相关命令(xccov view等)有较大更新,请参考 https://developer.apple.com/documentation/xcode_release_notes/xcode_11_release_notes?language=objc。主要的变化是 *.xcresult 文件中不再包含 .xccovarchive 文件。

本文涉及的所有脚本均托管在GitHub项目UnitTestParser中。

目录


单元测试基础

单元测试命令

执行如下命令即可进行单元测试。单元测试过程中产生的文件存放在BUILD_DIR目录。

1
2
3
4
5
6
7
# resultBundleVersion 为可选参数,为了防止后续版本更新导致结果文件变化,建议加上这个参数

# for xcodeproj
xcodebuild test -project Demo.xcodeproj -scheme Demo -derivedDataPath "${BUILD_DIR}/" -destination "${SIMULATOR_PLATFORM}" -resultBundlePath "${XCRESULT_PATH}" -resultBundleVersion 3

# for xcworkspace
xcodebuild test -workspace Demo.xcworkspace -scheme Demo -derivedDataPath "${BUILD_DIR}/" -destination "${SIMULATOR_PLATFORM}" -resultBundlePath "${XCRESULT_PATH}" -resultBundleVersion 3

SIMULATOR_PLATFORM指定使用的模拟器类型,如。不同机器上可用的模拟器类型不同,如 platform=iOS Simulator,OS=13.4,name=iPhone 11,不同机器上可用的模拟器类型不尽相同,可以通过如下命令获取可用的模拟器列表

1
xcrun simctl list

XCRESULT_PATH指定单元测试结果文件(.xcresult文件)的存放路径,这个参数是Xcode11的命令行工具中新增加的,便于直接获取到结果文件。使用Xcode10及之前版本时,我们必须去BUILD_DIR对应的目录中找这个文件。(其实在Xcode11中,.xcresult文件在BUILD_DIR对应的目录中也会有一份拷贝,不过当然是直接指定结果文件路径来的方便。)

单元测试结果

单元测试执行完成之后,就可以在XCRESULT_PATH找到产生的结果文件,也就是 .xccresult 文件。该文件打开后的目录格式为:

1
2
3
4
5
6
.
├── Data/
│ ├── data0~xxx
│ └── data0~xxx

└── Info.plist

通过解析这些文件,就可以获得单元测试概况、代码覆盖率等基本的单元测试数据。

.xcresult 文件也支持直接在Xcode中打开,双击打开后就可以在Xcode中看到本次单元测试的详情。

单元测试概况统计

单元测试概况统计需要使用官方提供的xcrun xcresulttool工具。

首先需要获取到出json格式的数据,并从json数据中解析出获取单元测试报告所需的id

1
2
# 解析成json数据,便于下一步获取id
xcrun xcresulttool get --format json --path path/to/xcresult_file

从json数据中获取id需要一连串复杂的json字段解析,这里我参考了fastlane的xcresult解析脚本,不再自己重新写一遍id提取逻辑。

获取到id之后,就能进一步拿到详细的单元测试报告数据。

1
xcrun xcresulttool get --format json --path path/to/xcresult_file --id $id

执行上面的命令之后,又可以获取到了一份json数据,继续解析这份json数据,就可以拿到需要的单元测试总用例数、失败用例数、告警数、执行时长等数据。这里同样参考上面提到的fastlane的xcresult解析脚本

为了简化上述逻辑,UnitTestParser项目提供了 unitTestInfo.rb 来直接提取需要的数据。

1
ruby unitTestInfo.rb --xcresult-path=path/to/xcresult_file --output-file=/path/to/output_file

例如执行:

1
2
3
4
╰─± ruby ../UnitTestParser/unitTestInfo.rb --xcresult-path=result.xcresult --output-file=result.txt
单元测试用例数:15
失败单元测试用例数:0
单元测试运行总时长:0.48s

同时这些数据也会被写入到 result.txt 文件中,便于其他工具读取。

1
2
3
4
╰─± cat result.txt
15
0
0.48

单例模式 是最常被开发者使用的设计模式之一,同时,可能也是最常被 滥用 的设计模式之一。

关于单例模式的好处,网上有很多资料,这里就不再赘述了。本文以iOS 为例,探讨下单例可能带来哪些使用上的问题,以及如何避免这些问题。

问题

  • 不必要的提前初始化

    由于单例模式的特点,当我们访问单例对象的任何一个属性时,都会造成单例对象的初始化。很多时候,我们在调用单例对象的时候,并没有意识到这样调用带来的副作用,就导致了很多单例对象被提前初始化。

    假设我们有一个播放器单例类CVideoPlayerManager,这个单例类负责所有视频的播放。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    // CVideoPlayerManager.h
    @interface CVideoPlayerManager

    @property(nonatomic, assign) BOOL enableRotation; // 是否允许播放器旋转

    - (void)playWithURL:(NSURL *)url;

    @end

    // CVideoPlayerManager.m
    @implementation CVideoPlayerManager

    + (CVideoPlayerManager *)sharedInstance {
    static dispatch_once_t once;
    static CVideoPlayerManager *manager;
    dispatch_once(&once, ^{
    manager = [[CVideoPlayerManager alloc] init];
    });
    return manager;
    }

    - (instancetype)init {
    self = [super init];
    if (self) {
    // 播放器初始化
    // 播放视图初始化
    // 通知注册
    // ...
    }
    return self;
    }

    正常情况下,当我们第一次播放视频的时候,播放器才会被初始化。但是有时候我们在程序启动后,就想更新播放器的一些配置项,比如:

    1
    [CVideoPlayerManager sharedInstance].enableRotation = NO;

    写完这句代码之后,播放器的配置就生效了,需求搞定!但是仔细看一下,这里调用了 [CVideoPlayerManager sharedInstance]方法,也就是说代码执行到这一行的时候,播放器实例就会被初始化了。而播放器的初始化代码里,往往包含着比较复杂的逻辑,这样的提前初始化,不仅仅导致了内存中过早的出现了一个播放器对象,更可能会造成代码逻辑上的问题。
    如果播放器单例初始化代码里还调用了一些其他单例类的方法,然后这些个单例类的初始化方法里又调用了再其他类的单例方法,整个应用程序内部的单例生命周期就会变的异常混乱。

  • 忘记销毁单例类

    单例类使用起来很方便,我们也都知道单例类在内存中只会有一个对象(也可以创建多个对象,这里不展开说明了),但是我们经常会忘记单例对象有时也是需要 销毁 的。很多情况下,我们创建的单例类并不需要存在于应用程序的整个生命周期。比如用户关闭某个模块后,这些模块特有的一些单例对象就不需要了,如果我们不把它们清理掉,它们就会一直保留在内存中。占用内存不说,还有可能执行一些不该执行的逻辑,带来安全隐患。

    考虑有一个音频播放器单例,这个音频播放器有一个对应的播放控件,它需要随着页面的切换不断更新,重新attach到当前页面上;同时,当用户主动关闭音频播放控件时,音频停止播放同时把播放控件从页面上移除。
    由于这个播放控件需要出现在应用程序的各个页面上,我们把这个播放器设计成了一个单例类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    // CAudioPlayerManager.h
    @interface CAudioPlayerManager

    + (CAudioPlayerManager *)sharedInstance;
    - (void)playWithURL:(NSURL *)url;

    @end

    // CAudioPlayerManager.m
    @implementation CAudioPlayerManager

    static CAudioPlayerManager *s_manager
    + (CAudioPlayerManager *)sharedInstance {
    static dispatch_once_t once;
    dispatch_once(&once, ^{
    s_manager = [[CAudioPlayerManager alloc] init];
    });
    return s_manager;
    }

    - (instancetype)init {
    self = [super init];
    if (self) {
    // 初始化
    // 监听页面切换通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handlePageChanged:) name:CPageChangedNotification];
    }
    return self;
    }

    - (void)_handlePageChanged:(NSNotification *)notificaion {
    NSLog(@"page changed.");
    [self reAttach];
    }

    - (void)reAattach {
    // 将播放条重新添加到当前页面上
    }

    我们看到,CAudioPlayerManager是一个单例类,它监听了当前页面变化的通知,并在收到通知后有一些处理逻辑。当需要播放音频时,我们创建一个该单例的对象,然后去执行相关的逻辑,这都没问题。
    但是,当用户关闭这个音频播放控件时,整个音频播放器就在页面上不存在了。而CAudioPlayerManager里并没有提供一个销毁单例的方法。也就是说,虽然我们把播放器关闭了,也把音频播放控件从页面上移除了,但是CAudioPlayerManager的这个单例还一直存在于内存中。它还会继续监听页面变化的通知,继续执行处理逻辑。如果 reAattach 方法里没有进行完备的状态检验,这时候的代码可能就出问题了。再者,退一步说,即使不会对代码逻辑造成影响,_handlePageChanged:方法里的 NSLog 也会一直输出,污染了设备日志。

解决方案

说了这么多,那该如何去避免这些问题呢?这就需要我们合理地使用单例,不要不分场景地滥用单例。

  • 这个类是否有必要作为单例类。

    很多时候,我们都并不是需要一个单例类,而只是为了图方便,所以把它直接设计成一个单例类。这就需要我们在写代码之前,就想清楚自己是不是必须要使用单例模式,详细考虑单例模式的优缺点以及可能遇到的问题,比如创建实例的开销如何,是否有很多全局状态需要保存等。
    比如上面提到的视频播放器类 CVideoPlayerManager,其实不一定非得是一个单例类。我们只需要每次在播放视频时,重新创建一个播放器实例即可,这样也能避免很多全局状态带来的逻辑问题。当然一些全局的配置可以单独放到一个单例类里去,做统一的管理。

  • 即使有些全局状态需要保存,也不一定非得用单例类来实现。

    我们在使用单例类时,经常是为了保存一些全局状态。但是即使是要保存全局状态,也不一定非要把这个类作为单例类使用。我们可以使用static变量来维护一个全局对象。
    下面提供一个简单的代码示例来说明如何使用static变量而不是单例类来维护全局对象。这里并不是说就不让用单例,只是提供另外一个可行的方案。大家在写代码时,还是需要自己去思考用那种实现方式比较合理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // CDataUtils.h
    @interface CDataUtils

    + (NSString *)urlAtIndex:(NSInteget)index;

    @end


    // CDataUtils.m
    static NSArray<NSString *> *s_urlList = nil;

    @implementation CDataUtils

    + (void)initialize {
    static dispatch_once_t once;
    dispatch_once(&once, ^{
    s_urlList = ...;
    });
    }

    + (NSString *)urlAtIndex:(NSInteget)index {
    return [urlList objectAtIndex:index];
    }

    @end

    这样还有一个好处是,当我们需要给CDataUtils添加更多的类方法时,不会带来任何副作用。因为调用类方法不会涉及到任何实例的初始化。

  • 为单例提供一个销毁实例的方法

    如果是像上面说的CAudioPlayerManager这种需要销毁当前实例的单例类,我们可以给单例类提供一个销毁实例的方法。这样当我们不想要当前的实例时,就可以直接调用这个方法把实例从内存中移除。
    同时,我们还需要提供一个判断当前是否有实例的方法,如果当前没有实例,同时也不需要去创建一个新的实例,那么就可以直接把调用逻辑return调,防止错误地创建了新的实例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    // CAudioPlayerManager.h
    @interface CAudioPlayerManager

    + (CAudioPlayerManager *)sharedInstance;
    + (void)dispose;
    + (BOOL)isSingletonDisposed;

    @end

    // CAudioPlayerManager.m
    @implementation CAudioPlayerManager

    static CAudioPlayerManager *s_manager;
    + (CAudioPlayerManager *)sharedInstance {
    static dispatch_once_t once;
    dispatch_once(&once, ^{
    s_manager = [[CAudioPlayerManager alloc] init];
    });
    return s_manager;
    }

    + (void)dispose {
    if (s_manager) {
    s_manager = nil;
    }
    }

    + (BOOL)isSingletonDisposed {
    return s_manager == nil;
    }

总结

单例模式是个非常好用也非常实用的设计模式,但是任何设计模式,一旦滥用,都会带来很多问题,单例模式更是这样。合理使用单例模式,需要我们在写下决定使用单例之前,想清楚是否该使用单例模式,是否有其他更好的替代方案。

在日常开发中,我们经常需要通过scheme打开指定的页面。这时候我们一般会在打开手机 Safari ,在地址输入框中输入myapp://somepage?pageid=xxx进行页面的跳转。但是由于移动手机的尺寸限制,每次输入这些scheme都挺繁琐,效率低且容易出错。如果能直接在电脑上输入scheme,然后就能打开手机APP里的指定页面,那该多好啊。

模拟器可以直接通过 xcrun simctl openurl booted scheme 打开scheme

考虑到需要在电脑上控制手机里的APP,那就肯定需要电脑和手机进行通信了,这里主要想到两种方式:

  • 使用WiFi连接,电脑和手机连接到同一个局域网中,使用socket通过网络协议通信。
  • 使用USB连接,将电脑和手机直接连接在一起。

WiFi连接的问题是连接不稳定,容易断开,我们从Xcode的无线调试功能中也能领略到。
USB连接的话,既然是有线连接,稳定性自然得到了保证,也不需要切换网络。但是如何通过USB来进行电脑和手机的通性呢?会不会开发起来很复杂?

进一步研究发现,Apple提供了一个叫 usbmux的东西来实现电脑和手机基于USB的通信,iTunes和Xcode都用到了这个东西,所以通过USB连接进行通信方案肯定是可行的了。

接下来就是好好研究研究usbmux了,苹果的官方文档是这样说的:

During normal operations, iTunes communicates with the iPhone using something called “usbmux” – this is a system for multiplexing several “connections” over one USB pipe. Conceptually, it provides a TCP-like system – processes on the host machine open up connections to specific, numbered ports on the mobile device. (This resemblance is more than superficial – on the mobile device, usbmuxd actually makes TCP connections to localhost using the port number you give it.)

简单翻译一下,就是usbmux在USB协议上实现多路TCP连接,将USB通信抽象为TCP通信。这样一来,通过USB连接电脑和手机之后,电脑和手机就可以通过建立TCP连接进行通信了!这样说可能有些抽象,下面通过这个图来简要介绍下基于usbmux的电脑和手机通信流程。

img

  1. 电脑端iTunes或其他第三方应用程序向usbmuxd发起连接,请求获取USB连接的通知。
  2. 手机通过USB连接到电脑上
  3. usbmuxd监听到有手机连接,并向手机发送一个packet
  4. 手机回复
  5. usbmuxd确认手机已经成功连接,并通知电脑端应用程序
  6. 电脑端应用程序向usbmuxd发起建立新的一条连接,请求连接手机的指定端口
  7. usbmuxd向手机发送“假的TCP”(基于USB的TCP) SYN packet
  8. 手机收到SYN packet并在指定端口打开TCP连接
  9. 手机回复 TCP SYN/ACK 确认端口已经打开可以进一步建立连接
  10. usbmuxd向手机发送 ACK 完成握手
  11. usbmuxd向电脑端应用程序发送连接建立成功的通知
  12. 之后电脑端应用程序向usbmuxd发送的socket,都会经过USB-TCP协议发送到手机的指定端口上,然后手机端的应用程序可以通过监听该端口接受信息。手机向电脑传递信息,也是一样的流程。

开源库Peertalk就是封装了上面的一套流程,并提供了发起连接的接口以及收发消息的端口。这样一来,我们可以直接使用 Peertalk 实现基于USB的电脑和手机通信了。

下面以电脑端发送scheme信息给手机端应用程序为例,介绍下使用Peertalk进行通信的流程。

  • 电脑端应用程序需要监听手机连接的通知
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)startListeningForDevices {
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];

[nc addObserverForName:PTUSBDeviceDidAttachNotification object:PTUSBHub.sharedHub queue:nil usingBlock:^(NSNotification *note) {
// 手机插入处理
// ...
// 建立连接
[self connectToUSBDevice];
}];

[nc addObserverForName:PTUSBDeviceDidDetachNotification object:PTUSBHub.sharedHub queue:nil usingBlock:^(NSNotification *note) {
// 手机拔出处理
}];
}

- (void)connectToUSBDevice {
PTChannel *channel = [PTChannel channelWithDelegate:self];
[channel connectToPort:PTExampleProtocolIPv4PortNumber overUSBHub:PTUSBHub.sharedHub deviceID:connectingToDeviceID_ callback:^(NSError *error) {
// 连接建立成功
}];
}
  • 电脑端发出信息
1
2
3
4
5
6
7
8
- (IBAction)sendMessage:(id)sender {
NSString *message = @"qqnews://detail?newsid=xxx&commentid=xxx";
dispatch_data_t payload = PTExampleTextDispatchDataWithString(message);
[channel sendFrameOfType:PTExampleFrameTypeTextMessage tag:PTFrameNoTag withPayload:payload callback:^(NSError *error) {
// 发送完成
}];
}
}
  • 手机端监听该端口并处理接收到的消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 监听本地端口
PTChannel *channel = [PTChannel channelWithDelegate:self];
[channel listenOnPort:PTExampleProtocolIPv4PortNumber IPv4Address:INADDR_LOOPBACK callback:^(NSError *error) {
}];

// 处理接受到的消息
- (void)ioFrameChannel:(PTChannel*)channel didReceiveFrameOfType:(uint32_t)type tag:(uint32_t)tag payload:(PTData*)payload {
if (type == PTExampleFrameTypeTextMessage) {
PTExampleTextFrame *textFrame = (PTExampleTextFrame*)payload.data;
textFrame->length = ntohl(textFrame->length);
NSString *scheme = [[NSString alloc] initWithBytes:textFrame->utf8text length:textFrame->length encoding:NSUTF8StringEncoding];
// handle scheme
}
}

这样就实现了在电脑端输入scheme打开手机APP指定页面的功能,再也不用在手机 Safari 上输入一长串scheme了!但是这种方式有个缺陷,就是没法通过电脑端传递的scheme冷启动APP,因为接收端口消息并处理的逻辑都是在APP里,APP没启动,这些逻辑自然处理不了。这个问题接下来我会再研究研究。

话说回来,既然知道了如何让电脑和手机通信,那肯定不能仅满足于打开scheme这一个功能了!比如我们可以把客户端的日志通过这个流程发送给电脑,就可以在电脑上实时看客户端日志了。基于这个,我们可以做很多有意思的开发工具。

另外Facebook开源的桌面开发工具Flipper也使用了Peertalk,提供了查看日志、页面视图等功能,同时支持iOS和android。但是作为一个开发辅助工具,Flipper搞得有点复杂了,各种依赖一大堆,不过其特性的实现代码和设计思想还是值得研究的。

参考链接:

随着项目的推进和业务的增长,代码中不可避免地会出现很多重复代码,这些重复代码大多是从某个地方拷贝过去的,所以我们称之为Copy-Paste-Code。尤其是项目成员较多时,大家碰到不太熟悉的业务模块,经常会直接copy前人写好的代码,一来简单迅速,二来风险较低,但却会导致Copy-Paste-Code越来越多。这种代码让项目非常难以维护,一处修改,处处修改。

重复代码检测工具

如何找出项目中的重复代码呢?现在有很多的重复代码检测工具(Copy-Paste-Detector,简称CPD)可以帮助我们完成这件事情。CPD的原理比较好理解,就是一个字符串匹配算法。如果两个代码片段完全相同,即认为这两处代码是Copy-Paste-Code

PMD是一个比较流行的静态代码分析工具,支持多种语言。我们可以使用其提供的CPD对OC代码做重复检测。

安装

使用brew就可以安装PMD

brew install pmd

也可以自行下载源码运行。mac上可以按如下命令执行

$ cd $HOME
$ curl -OL https://github.com/pmd/pmd/releases/download/pmd_releases%2F6.6.0/pmd-bin-6.6.0.zip
$ unzip pmd-bin-6.6.0.zip
$ alias pmd="$HOME/pmd-bin-6.6.0/bin/run.sh"

检测

安装好PMD后,就可以直接检测项目中的重复代码。PMD-CPD有GUI工具,如下命令即可打开

pmd cpdgui 

在GUI工具中选择目录、语言、和检测阈值(demo中使用的是20,实际项目中建议取值在100到150之前,视具体情况而定)后,点击 Go 就可以检测了。

img

图中可以很清楚的看到哪些地方存在重复代码。有了这个检测结果,我们就可以拿来做针对性的修改与重构。

集成到Xcode

但是,只使用GUI工具检测出来重复还是不能够避免大家写代码时随心所欲的 Copy-Paste, 为了进一步规避Copy-Paste-Code,我们决定将这个工具集成到Xcode中去,只要出现了Copy-Paste-Code,让Xcode在编译期就发出警告,督促大家放弃 Copy-Paste,花几分钟时间思考更为合理的解决方案。

为了实现这个目的,我们需要分两步走:

  1. 找到所有重复代码及其所在的文件、行数
  2. 在Xcode中将warning展示出来

生成检测结果

第一步使用PMD-CPD就可以实现,上面提到的PMD-CPD不仅有GUI工具,还支持直接将重复代码检测结果以指定格式输出到文件中。

pmd cpd --files Demo --minimum-tokens 20 --language objectivec --encoding UTF-8 --format xml > output.xml

其中 files 用于指定需要检测的文件列表,minimum-tokens 用于设置最小重复阈值,format 用于指定输出文件的格式。PMD-CPD支持xml、csv、txt等格式,为了方便下一步处理,这里使用xml格式。生成的xml文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>
<pmd-cpd>
<duplication lines="7" tokens="23">
<file line="24"
path="..Demo/ViewController.m"/>
<file line="36"
path="..Demo/ViewController.m"/>
<codefragment><![CDATA[- (int)hello0 {
int a = 0, b = 0;
int c = a + b;
return c;
}

- (int)hello1 {]]></codefragment>
</duplication>
</pmd-cpd>

展示Xcode warnings

拿到这个文件后,我们就可以实施第二步了。关于如何实现在Xcode中生成自定义的warning提示,可以参考之前一篇文章Xcode展示自定义warning提示。简而言之,我们需要在检测出来的每一处重复代码的位置,按如下形式在Xcode中输出一行warning:

echo "${SRCROOT}/Demo/ViewController.m:1:1: warning: xxx copy-paste lines from xxx"    

在Xcode的 Build Phases 中,我们增加一个新的 _Run Script_,并添加如下代码

# 检测并输出结果到output.xml
sh ./pmd_cpd/bin/run.sh cpd --files ${EXECUTABLE_NAME} --minimum-tokens 20 --language objectivec --encoding UTF-8 --format xml > output.xml
# 解析xml并生成Xcode warning
ruby parse.rb

解析脚本parse.rb代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
require 'rexml/document'
include REXML

file = File.new('output.xml')
doc = Document.new(file)
root = doc.root
root.each_element('duplication') { |item|
duplicatedFiles = []
item.each_element('file') { |file|
duplicatedFiles.push(file)
}
item.each_element('file') { |file|
duplicatedString = duplicatedFiles.select{|e| e != file}.map {|e| "#{e.attributes['path'].split('/').last}:#{e.attributes['line']}"}.join(', ')
puts "#{file.attributes['path']}:#{file.attributes['line']}:1: warning: #{item.attributes['lines']} copy-pasted lines from: #{duplicatedString}"
}
}

添加后直接编译,我们就能在Xcode中的warning列表中发现如下warning,大功告成了!

img

img

总结

使用重复代码检测工具可以方便地检测出重复代码,进一步解析检测结果并以warning的形式集成到Xcode中,在编译期间就可以很清晰地看到代码中的warning,方便发现已有重复代码,同时避免产生新的重复代码。

示例项目地址:https://github.com/JerryChu/Xcode-tips

在 Xcode 的 Build Setting 中,可以进行一些warning设置,便于在编译阶段提前发现代码问题,比如 隐式类型转换 或者 返回类型不匹配 等。但有时我们需要展示一些 自定义的warning提示 ,最常见就是对代码中的TODOFIXME等标签进行warning提示,以免之后忘记修改。我们想要的效果是这个样子:

img

如何在Xcode中展示warning提示

如何让 Xcode 展示出我们自定义的waring呢?其实很简单,只要我们按如下格式输出一段文本,Xcode就会把这段文本在对应的位置展示出来。

/path/to/file:row: warning: text
/path/to/file:row:column: warning: text

那么我们怎么去输出这段文本呢?我们知道Xcode的 Build Phases 中,可以添加 Run Script 。没错,我们只需要在 Run Script 中输出这段文本就行了。

img

点击图中左上角的“+”,选择 New Run Script Phase,在输入框中添加如下脚本,然后编译。

echo "${SRCROOT}/Demo/ViewController.m:1:1: warning: haha"    

编译之后,我们就能看到Xcode左侧的warning列表中出现了一条warning,点击这条warning,就能够像Xcode自己输出的warning一样,自动定位到对应的位置。这样我们就能够在ViewController.m的第一行第一列看到一条warning提示。

img

img

按同样的方法也可以展示自定义的error提示,格式如下:

/path/to/file:row: error: text
/path/to/file:row:column: error: text.

如何检测代码中的指定标签

知道了如何展示自定义的warning后,接下来的目标就很清晰了,即如何去找到TODOFIXME等标签所在的文件及行数。
为了实现这个功能,我们需要遍历每一个文件的每一行代码,检测代码中是否出现了对应的标签。这一步我们可以用下面的脚本实现。参考自 https://krakendev.io/blog/generating-warnings-in-xcode

# 需要检测的标签
TAGS="TODO:|FIXME:"
# 对目录下所有.m .h文件进行逐行的匹配检测
find "${SRCROOT}" \( -name "*.m" -or -name "*.h" \) -print0 \  
        | xargs -0 egrep --with-filename --line-number --only-matching   "($TAGS).*\$"

如果有匹配行,那我们就需要按上面提到的指定格式进行输出。由于我们已经能拿到文件地址以及匹配行,所以接下来只需要按格式输出即可。这里使用perl语言可以很方便地实现。

TAGS="TODO:|FIXME:"  
 find "${SRCROOT}" \( -name "*.m" -or -name "*.h" \) -print0 \  
        | xargs -0 egrep --with-filename --line-number --only-matching   "($TAGS).*\$" \  
     | perl -p -e "s/($TAGS)/ warning: \$1/"

再次编译,左侧的warning列表中就会出现另一个warning,点击这个warning,会看到所在行有一个TODO:标签。这样就实现了添加自定义warning提示的功能。

img

总结

了解Xcode展示warning的原理后,我们可以基于这个功能做很多有趣的事情。除了本文提到的对指定标签展示warning提示外,还可以在Xcode中集成重复代码检测工具,或者进行代码风格提示等。Xcode是一个强大的编译器,还有更多有意思的功能等着我们去探索。

示例项目地址:https://github.com/JerryChu/Xcode-tips

不出所料,iOS 12 果然没有什么大改动出现,所有支持 iOS 11 的设备都可以升级到 iOS 12,称之为 iOS 11bugfix 版本也不为过。对开发者来说,这未必不是一件好事,毕竟 iOS 11 的bug的确有点多。同时,更少的改动意味着更少的适配工作,大家可以安心的写bug了。

不过,作为一次大版本更新,iOS 12 还是有一些特性可以说一说的。

  • 明显的性能提升
    大会keynote上写着APP启动时间提升了 (up to)40%。虽然有个 up to_,但是如果真能有这个级别的提升,那还真挺令人激动的,毕竟大家一直在探索各种降低启动时间的方法。从另一个方面想,_iOS 11 到底是有多慢…

img

  • Siri Shortcut
    使用 siri 完成一系列预定事件,标志着Siri终于支持workflow,看着是个不错的功能。但是明显能感觉到苹果在推进 Siri 的路上走得并不快。而且从之前接入SiriKit的经验来看,预计会有不少坑,比如识别的语法太固定,识别的场景太狭窄,而且之前接入SiriKit之后出现各种各样的审核问题和奇怪的崩溃问题让人很心累。希望这一次 Siri 可以做得更好。

  • ARKit
    今年仍然花了不少时间来讲ARKit,并且发布了ARKit 2ARKit 2开始支持再拍照时测量尺寸了,这是个挺好用也好玩的功能。只不过 MeasureKit 的开发者可能就没时间这么想了,看来是得赶紧开发点新东西了。同时ARKit 2也支持了在拍摄视频或者视频聊天时实时在头上盖一个(想不到用啥形容词了…)动物,可以实时模拟你的表情。不过既然ARemoji都已经支持模拟真人了,所以被吐槽一下也就很正常了。

  • Grouped Notifications
    系统通知终于支持分类了,再也不用被微信通知刷屏了。系统通知的分类会按照APP设置的通知分类来进行组织,属于一类的通知在展示时会叠加在一起,点击之后可以展开全部,并且支持一些自定义的文案。这部分功能需要开发者进行些适配工作(设置指定的属性等),如果不进行适配的话,APP的所有通知默认全部叠加在一起,这很显然是大家不希望看到的。稍后会补充个demo进来。demo:https://github.com/JerryChu/Demo

  • Photos Share
    iOS 12 中,相册会自动识别出照片中的联系人并推荐你将此照片分享给该联系人。不仅如此,你的朋友在收到你分享过来的照片之后,系统也会推荐TA礼尚往来地分享一些包含你的照片给你(如果找不到就尴尬了)。这一切都是基于图像识别实现的,所以首先,你得在通讯录里加上你朋友的照片。

  • Screen Time
    iOS 12 在设置页面新增了一个Screen Time(屏幕时间)选项,在这里你可以看到所有APP的使用时长等信息。这下终于能知道时间都去哪儿了。更爆炸的是,这个页面还有一个App Limits(应用限制)功能,可以限制每个APP的使用时长,时间到了之后直接就把应用锁住不让用了,很适合作为一款防沉迷神器。这简直是熊孩子的噩梦,熊孩子家长的福音啊。

img

  • Group Facetime
    FaceTime 支持群聊啦!咦,这好像不是啥新功能,微信不早就可以了吗?!最多支持 32 人同时在线群聊哦!呵呵,想象下32个人在一个聊天室里是啥场景,就算手机不爆炸,人也该爆炸了。上限不是20,也不是50,是整整 32,看来苹果工程师还是有情怀的。

  • IAP
    刚听说微信公众号的赞赏功能又要上线了,苹果还真是做出了让步啊!不过对开发者和测试同学来说,有个更好的消息,sandbox测试账号与实际账号分离出来了,会支持设置一个单独的测试账号,再也不用在手机上来回切换啦!

除此之外,mac OS 还有几个比较重要的改动。mac OS 延续了名字很难读的特性,Mojavi,也不知道是啥,但是看着屏幕背景图还挺好看。mac OS Mojavi 支持夜间模式了!体验了下,显得还挺高端的。但是 iOS 目前不支持,所以这部分的适配工作也可以先挂起了🤓。

最后要说一下Xcode,今年 Xcode 有不少新特性加入,个人认为应该是这次 WWDC 最大的亮点了。Xcode 10 进一步增加了对gitlabgitbucket的支持,同时会自动拉取代码,并在可能出现冲突时提前进行提示。同时 Xcode 10 支持并行跑单元测试,在代码覆盖率检测等工具上也都有很多的提升,逐渐要长成一个代码编辑器应该有的样子了。。同时 Xcode 也支持了夜间模式,让大家在夜深人静的时候能够更舒服地挖坑,苹果真的是很了解码农们的需求啊!

到现在设备都还没挂过,从beta版本的使用情况来看,iOS 12 确实还挺稳定的。希望苹果能越来越好!

基本也就这些了,更多更详细的内容,大家可以看 WWDC 的视频。欢迎交流讨论!

门票 & 签证

WWDC2018 6月4号在圣荷塞会议中心举办。每年的WWDC差不多都是在6月初举办,一般是3月初开始预约报名,3月底就能够知道有没有抽中门票。一万多人民币的门票还能让大家哄抢,苹果爸爸也真是厉害。

如果抽中了门票,就得赶紧去办签证了(有签证的大神请绕行,谢谢)。美国签证是需要本人面谈的,前期的 DS-160 表格填写需要个一两天,预约面签一般只能约到一两周之后了,而且办理美国签证可能会被 行政审核,一旦进入行政审核流程,至少需要 4周 才能搞定签证。这样算下来,时间就已经很紧张了。如果直接通过面试,一般一周之内就能拿到签证。所以签证尽量提前办,甚至可以在报名的时候就开始办,即使抽不中,也总有机会去美国玩儿的。

如果没抽中门票的话,首先恭喜你省下了一万多块钱😂。 不过实在想去的话,还是有些办法的。苹果不允许私下转让门票,但是同一个企业开发者账号下的开发者是可以互相转让的,所以你可以看看公司有没有抽到票不想去的,一手交钱,一手交门票。

本人一直运气很好,这次也不必多说,肯定是…没抽中!不过幸亏有同事愿意转让,于是一顿操作之后总算是搞定了门票。在此特别感谢给我门票的同事以及本人leader帮忙协调, 让我这次WWDC之行得以实现🙏。

延续本人运气一直很好的特性,办理签证当然也是顺利地…被行政审核了!其实,没有直接被被拒绝,我已经很满足了,因为本人没出过国白本一个,名下也没有任何固定资产,去面签之前还是很忐忑的。当然,美签的通过率还是很高的,只要不出特殊状况都不会被直接拒绝,只是有些情况容易被行政审核,比如专业或者职业比较敏感。我本科学的是人工智能相关专业,美国最近又谨防大家过去偷技术,所以我这个专业被列为了敏感专业(好像有个敏感专业和敏感职业列表,可以在网上找找)。嗯…,一定是专业敏感导致的,一定不是因为我穷🤫。

我身边办理美国签证的同事中,一半是直接通过了,一半是行政审核之后也顺利拿到了签证,所以不必太担心。不过,被行政审核之后,一般拿到的都是一年有效期的签证,而不是十年签。之后再想去,只能再花一千多重新办下签证。不管怎样,总的来说通过率还是很高的,所以一定要有信心,面签的时候千万别紧张,要不面签官以为你想搞事情呢,可能就给你挂了。温馨提示,进入美国使馆前,需要将所有不相关物品包括手机都寄存起来,寄存时要去旁边的正规的店里寄存,一定 不要相信!不要相信!不要相信! 路边极其热情拉着你让你存包的人,要不你会被坑一笔钱。不要问我是怎么知道的。。。

交通

去参加WWDC的话,大部分人都会直飞圣荷塞 SJC 或者先飞旧金山 SFO 再转往圣荷塞。如果时间充足,可以先飞旧金山逛一逛,稍作休整再去圣荷塞。美国公共交通不是很完善,线路少且间隔时间较长,如果会开车的话,开车是最优选择。我是对自己车技不是很信任,怕半路出点事不方便,就索性公交出行了。

从旧金山机场到市区,可以乘坐 BART(Bay Area Railway Transit)轻轨,差不多需要 10美元。在美国买公交票也和国内不太一样,需要先查好到某站多少钱,然后把钱放进去,在机器上一顿加加减减,直到正好等于你的票价,最后点一下购买,等出票就行了。也不知道钱选多了会是啥后果。BART 以及后面会提到的 CalTrainVTA(Valley Transportation Authority)都是类似的操作,但是车票还不互通,每次都需要单独购买,比较麻烦。

BART车厢:

img

市内游玩的话可以坐 Muni(San Francisco Municipal Railway)公交车,这个票价各种各样比较复杂,不是很清楚。当然你也可以选择暴走,顺路体验下风土人情。不差钱的我当然是选择了这种方式。

从旧金山到圣荷塞可以坐 CalTrain 火车,票价 9美元 ,一路上风景还不错,会经过 MountainView斯坦福大学 等地,可以体验体验。只是从旧金山市区走到 CalTrain 火车站中间貌似有一段贫民区,看到很多乞丐,还挺害怕的。美国路上乞丐不少,旧金山尤其多,所以晚上尽量就别出门了。

CalTrain车厢:

img

CalTrain车票:

img

如果是坐 CalTrain 从旧金山到圣荷塞的话,需要从 San Jose Ditridon 站下车,然后换乘 VTA 小火车,坐两站就可以到WWDC会议举办地 San Jose Mcentry Convention Center。这个小火车是全程统一价,2.5美元

VTA车厢:

img

VTA车票:

img

总的来说,乘坐公共交通票价还是相对便宜的,只是会略有些麻烦。如果不开车的话,以上的所有行程,都可以使用出租车或者Uber、Lyft代替,只是会有些小贵。我在圣荷塞市内一般都是用Lyft, 几乎随叫随到,很方便。3公里左右的路程差不多需要 10美元,还是可以接受的。

不写了,就介绍到这儿吧,为了抢个好位置,已经排了快3个小时的队了,但是前面已经是人山人海了😵。还剩最后一小时,WWDC 2018,我来啦!

第三方分享模块是各种各样的APP中必不可少的一个模块,最常用的就是分享到微信和QQ。本文基于面向协议编程(Protocol Oriented Programming,POP)的思想,介绍如何实现一个 简单灵活易于扩展 的第三方分享模块。

通常,我们需要实现以下几种功能:

  • 分享一段文本
  • 分享一张图片
  • 分享一个网页
  • ……

文本分享和图片分享比较简单,需要的数据较少。下面以微信的网页分享为例,介绍如何使用面向协议的思想实现分享功能。

网页分享,也就是分享了一个网页,用户在微信或QQ中点击分享card(如下图所示),会跳转到一个网页去。通常,这个分享card需要展示内容标题、内容描述、缩略图等数据,因此进行网页分享时,一般需要传递以下数据:

img

  • 分享页面的url
  • 分享card的标题
  • 分享card的描述
  • 分享card的缩略图

在微信分享SDK中,需要使用到以下两个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@interface WXMediaMessage : NSObject
/** 标题
* @note 长度不能超过512字节
*/
@property (nonatomic, retain) NSString *title;
/** 描述内容
* @note 长度不能超过1K
*/
@property (nonatomic, retain) NSString *description;
/** 缩略图数据
* @note 大小不能超过32K
*/
@property (nonatomic, retain) NSData *thumbData;
/**
* 多媒体数据对象,可以为WXImageObject,WXMusicObject,WXVideoObject,WXWebpageObject等。
*/
@property (nonatomic, retain) id mediaObject;
// ...
@end
1
2
3
4
5
6
7
@interface WXWebpageObject : NSObject
/** 网页的url地址
* @note 不能为空且长度不能超过10K
*/
@property (nonatomic, retain) NSString *webpageUrl;
// ...
@end

按照面向对象编程的思想,假设要分享的数据是一个ShareModel,你很快就实现了这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation ShareHelper
+ (void)shareObjectToWeixin:(ShareModel *)model {
WXWebpageObject *object = [WXWebpageObject object];
object.webpageUrl = model.url;
WXMediaMessage *message = [WXMediaMessage message];
message.title = model.title;
message.description = model.description;
message.thumbData = model.thumbnailData;
message.mediaObject = object;

SendMessageToWXReq *req = [[SendMessageToWXReq alloc] init];
req.message = message;
[WXApi sendReq:req];
}
@end

代码并不复杂,用着也方便。有一天,产品过来和你说另一个页面也要支持分享。你看到另一个页面分享时用到的对象是AnotherObject,于是你马上Ctrl+CCtrl+V,不到一分钟就搞定了这个需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation ShareHelper
+ (void)shareAnotherObjectToWeixin:(ShareModel *)model {
WXWebpageObject *object = [WXWebpageObject object];
object.webpageUrl = model.url;
WXMediaMessage *message = [WXMediaMessage message];
message.title = model.title;
message.description = model.description;
message.thumbData = model.thumbnailData;
message.mediaObject = object;

SendMessageToWXReq *req = [[SendMessageToWXReq alloc] init];
req.message = message;
[WXApi sendReq:req];
}
@end

正当你沉浸在实现需求的喜悦中时,产品又跑过来说,你这个分享功能还需要支持使用动态下发的数据进行分享,你发现需要支持分享词典数据。虽然有些纠结,你还是在ShareHelper中又加了一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation ShareHelper
+ (void)shareDictionaryToWeixin:(NSDictionay *)dict {
WXWebpageObject *object = [WXWebpageObject object];
object.webpageUrl = dict[@"url"];
WXMediaMessage *message = [WXMediaMessage message];
message.title = dict[@"title"];
message.description = dict[@"description"];
message.thumbData = dict[@"thumbnailData"];
message.mediaObject = object;

SendMessageToWXReq *req = [[SendMessageToWXReq alloc] init];
req.message = message;
[WXApi sendReq:req];
}
@end

实现功能之后,看着ShareHelper.h,作为一个优秀的程序员,你有点方,因为你感觉到需要提供的接口会越来越多。

1
2
3
4
5
6
7
8
@interface ShareHelper : NSObject
// 分享ShareModel数据
+ (void)shareObjectToWeixin:(ShareModel *)model;
// 分享AnotherShareModel数据
+ (void)shareAnotherObjectToWeixin:(ShareModel *)model;
// 分享Dictionary数据
+ (void)shareDictionaryToWeixin:(NSDictionay *)dict;
@end

那么涉及到多种不同数据的分享,有什么比较好的办法能让 ShareHelper 别再继续膨胀下去呢?
使用面向协议编程的思想来分析这个问题,就会豁然开朗了。
从分享模块本身来说,其实它并不需要关注外部传递的是ShareModelAnotherShareModel还是NSDcitionary,它只需要 网页url标题简介缩略图 这四个数据。因此,无论是什么对象,只要能提供这四个数据,分享模块就可以把它分享出去。

1
2
3
4
5
6
7
8
@protocol Shareable <NSObject>

- (NSString *)shareTitle;
- (NSString *)shareDescription;
- (NSString *)shareUrl;
- (NSData *)shareThumbnailData;

@end

定义好这个协议之后,ShareHelper会变得非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@interface ShareHelper : NSObject
+ (void)shareToWeixin:(id<Shareable>)shareableModel;
@end

@implementation ShareHelper
+ (void)shareToWeixin:(id<Shareable>)shareableModel {
WXWebpageObject *object = [WXWebpageObject object];
object.webpageUrl = [shareableModel shareUrl];
WXMediaMessage *message = [WXMediaMessage message];
message.title = [shareableModel shareTitle];
message.description = [shareableModel shareDescription];
message.thumbData = [shareableModel shareThumbnailData];
message.mediaObject = object;

SendMessageToWXReq *req = [[SendMessageToWXReq alloc] init];
req.message = message;
[WXApi sendReq:req];
}
@end

这时,你就可以自豪地和同事们说,以后你们不论想分享啥,只要实现Shareable这个协议,就能给你分享出去!

对于文本分享和图片分享,也只需要提供对应的协议即可,实现了该协议的数据,就可以按照对应的形式分享出去。

1
2
3
4
5
6
7
@protocol ShareableText <NSObject>
- (NSString *)shareText;
@end

@protocol ShareableImage <NSObject>
- (UIImage *)shareImage;
@end
1
2
3
4
@interface ShareHelper : NSObject
+ (void)shareTextToWeixin:(id<ShareableText>)shareableModel;
+ (void)shareImageToWeixin:(id<ShareableImage>)shareableModel;
@end

习惯了面向对象编程之后,我们经常会发现写出的代码不够通用、不好扩展,尤其是对于工具类的方法。如果能将面向对象编程面向协议编程结合起来使用,扬长避短,经常可以达到事半功倍的效果。

iOS编程中,需要在主线程中进行操作时,我们经常会用到以下代码:

dispatch_async(dispatch_get_main_queue(), ^{ 
    // TODO 0:     
});

仔细观察这部分代码, dispatch_get_main_queue() 实际获取的是 主队列(Main Queue) 。我们看dispatch_get_main_queue() 的官方文档:

Returns the main queue. This queue is created automatically on behalf of the main thread before main() is called.

而当我们需要判断当前线程是不是 主线程(Main Thread) 时,会这样写:

if ([NSThread isMainThread]) {
    // TODO 1:
}

那么我们会想到以下几个问题:

1. 主线程和主队列到底有什么关系?
2. 为什么通过 dispatch_get_main_queue() 就可以确保在代码在主线程执行了?
3. 主线程可以执行非主队列里的任务吗?

我们知道,主队列是系统自动为我们创建的一个串行队列,因此不用我们手动创建。在每个应用程序只有一个主队列,专门负责调度主线程里的任务,不允许开辟新的线程。也就是说,在主队列里的任务,即使是异步的,最终也只能在主线程中运行。因此,开头的第一段代码是可以保证在主线程中运行的。我们使用如下代码做测试:

- (void)testMainThread {
    NSLog(@"begin");
    for (int i = 0 ; i < 10; i ++) {
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"current Thread: %@; Task: %@",[NSThread currentThread], @(i));
        });
    }
    NSLog(@"end");
}

输出为:

2016-08-28 21:22:18.384 Test[72358:1633765] begin
2016-08-28 21:22:18.384 Test[72358:1633765] end
2016-08-28 21:22:18.388 Test[72358:1633765] isMainThread: 1; Task: 0
2016-08-28 21:22:18.388 Test[72358:1633765] isMainThread: 1; Task: 1
2016-08-28 21:22:18.388 Test[72358:1633765] isMainThread: 1; Task: 2
2016-08-28 21:22:18.388 Test[72358:1633765] isMainThread: 1; Task: 3
2016-08-28 21:22:18.389 Test[72358:1633765] isMainThread: 1; Task: 4

由于block内的任务是异步执行,主线程在将当前方法(testMainThread )执行完毕之后,才会去继续执行主队列里的任务。那么如果我们把异步任务换成同步的出现什么结果呢?

- (void)testMainThread {
    NSLog(@"begin");
    for (int i = 0 ; i < 10; i ++) {
        dispatch_sync(dispatch_get_main_queue(), ^{
            NSLog(@"current Thread: %@; Task: %@",[NSThread currentThread], @(i));
        });
    }
    NSLog(@"end");
}

输出为:

2016-08-28 21:26:29.918 Test[72386:1636378] begin

也就是说,主线层被阻塞了。这个也好理解,因为此时主队列里的任务是同步执行的,同步任务必需被立刻执行。但同时由于主线程里的testMainThread 还没有执行完,主线程没法去处理主队列里的任务,导致程序死锁。也就是说主线程空闲时才会调度队列中的任务在主线程执行, 如果当前主线程正在有任务执行,那么无论主队列中当前被添加了什么任务,都不会被调度。 这也是我们平常写代码需要注意的。

主队列的任务一定在主线程中执行,那么我们再来看最后一个问题:主线程可以执行非主队列里的任务吗? 我们使用下面的代码做测试:

 NSLog(@"isMainThread: %@", @([NSThread isMainThread]));
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    NSLog(@"isMainThread: %@", @([NSThread isMainThread]));
});

在主线程中同步执行一个后台队列(使用dispatch_get_global_queue() 方法获取系统创建的全局并发队列)的任务。运行输出为:

2016-08-28 21:55:26.262 Test[72533:1649394] isMainThread: 1
2016-08-28 21:55:26.263 Test[72533:1649394] isMainThread: 1

可以看到,这个后台队列的任务也是在主线程中执行的。下面是从 http://blog.benjamin-encz.de 的一篇博客 摘抄下来的一段说明:

While doing some research for this post I found a commit to libdispatch that ensures that blocks dispatched with dispatch_sync are always executed on the current thread. This means if you use dispatch_sync to dispatch a block from the main queue to a concurrent background queue, the code executing on the background queue will actually be executed on the main thread. While this might not be entirely intuitive, it makes sense: since the main queue needs to wait until the dispatched block completed, the main thread will be available to process blocks from queues other than the main queue.

所以,主线程是可以执行主队列之外其他队列的任务的。即使[NSThread mainThread] 判断当前线程是主线程,也不能保证当前执行的任务是主队列的任务(系统并没有为我们提供一个判断是不是在主队列的API)。

通过对列表cell的数据,如cell的高度、subView的布局数据等进行缓存,可以避免不必要的重复计算带来的性能开销,从而实现对列表性能的深度优化。同时可以结合MVVM中ViewModel的概念,进行cell数据的存储,使得代码结构更加清晰。

在客户端开发中,最经常使用的就是各种列表了,列表性能的好坏,很大程度上决定了一个应用的使用体验如何。关于如何优化列表性能,大家基本都能说出来一些基本方法,比如:

  • cell复用
  • 在子线程进行耗时操作,避免阻塞主线程
  • 避免离屏渲染
  • 图片预处理
  • 减少subView数量
  • 不要给cell动态添加subView
  • ……

在实际开发过程中,我们经常会发现,即使已经采用了上面的方法,列表的性能还是不尽如人意。如何能更进一步地进行列表性能的优化呢?下面我们从列表中 cell的数据缓存 方面探讨一下解决方案。

cell高度缓存

对cell高度的缓存已经是业界比较通用的方案。列表每次展现cell时,都会执行回调方法获取cell的高度。

  • 列表reload时,会重新计算所有cell的高度。
  • 由于存在cell的复用,当从复用池中取出cell时,需要重新计算cell的高度。

正常情况下,每条数据对应的cell高度其实是一定的,当一条数据的对应的cell高度计算出来时,可以将高度存到某个地方,之后再展示这条数据时,就可以直接返回已经计算好的高度。关于cell高度的存储,一般有以下两种方式:

  1. 存储在Dictionary
    这种方式需要每条数据都有一个唯一标识,作为存储高度的key
1
2
3
4
5
6
7
8
9
10
11
12
// self.cellHeightDict = @{"identifier_0" : "height_0", "identifier_1" : "height_1"};

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// 获取数据的identifer
if (self.cellHeightDict[identifier]) {
return self.cellHeightDict[identifier].CGFloatValue;
} else {
// calculate height
self.cellHeightDict[identifier] = @(height);
return height;
}
}
  1. 存储在Model
    这种方式将数据计算出的高度作为一个属性,添加到数据对象中。相比于第一种方式,这种方式更易于理解,同时更加安全。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@interface Model : NSObject

@property(nonatomic) CGFloat calculatedHeight;
@property(nonatomic) BOOL isHeightCalculated;

@end

// ---

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// 获取数据(model)
if (model.isHeightCalculated) {
return model.calculatedHeight;
} else {
// calculate height
model.isHeightCalculated = YES;
model.calculatedHeight = height;
return height;
}
}

cell高度的缓存很好地结局了高度多次重复计算带来的性能开销。沿着这种思路,我们可以发现需要被缓存的并不只是cell的高度。

cell数据缓存

cell数据缓存方案不只局限于缓存cell的高度。cell及其subViews的布局数据,以及其他需要进行复杂计算的数据都可以缓存起来。
在日常开发过程中,我们经常遇到cell中有若干个垂直排列的label的情况,而每个label的高度都需要动态计算。一般来说,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// cell中有两个label垂直排列的情况

// 计算高度时,需要计算每个label的高度
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// 获取数据(model)
if (model.isHeightCalculated) {
return model.calculatedHeight;
} else {
CGSize size0 = [model.text0 sizeWithAttributes...];
CGSize size1 = [model.text1 sizeWithAttributes...];
CGFloat height = size0.height + size1.height;
model.isHeightCalculated = YES;
model.calculatedHeight = height;
return height;
}
}

// 展示时,对label进行布局
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
// ...

CGSize size0 = [model.text0 sizeWithAttributes...];
label0.text = model.text0;
label0.frame = (CGRect){0, 0, size0.width, size0.height};

CGSize size1 = [model.text1 sizeWithAttributes...];
label1.text = model.text1;
label1.frame = (CGRect){0, CGRectGetMaxY(label0.frame), size0.width, size0.height};

// ...

}

对于cell的高度,由于已经进行了缓存,没有发生重复计算。但是对于lable的size,每次对cell进行布局时,都会重新计算一遍。实际上,这种的字符串的尺寸计算是非常消耗性能的。因此,这些数据也应该被缓存起来。
那么,这些数据该使用哪种方式缓存起来呢?

  • 由于同一个identifier会对应多个数据(cell高度,label尺寸等),不适合直接使用词典存储。
  • 把这些数据都作为model的属性,会导致model过于复杂。

这种问题很适合使用 MVVM架构 来解决。

MVVM

MVVM中,VM(ViewModel)存储着V(View)布局所需要的数据。同样,对于每一个Cell(对应于MVVM中的V(View)),我们都可以创建一个对应的ViewModel来存储它布局所需要的数据。

每条数据(对应于MVVM中的M(Model))同样需要一个唯一标识,用于存储其对应的ViewModel。基本结构如下:

1
2
3
4
5
6
7
8
// ViewModel
@interface ViewModel : NSObject

@property(nonatomic) CGFloat height; // cell高度
@property(nonatomic) CGFloat lable0Frame;
@property(nonatomic) CGFloat lable1Frame;

@end

按照这个思路,列表cell布局的基本的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// self.viewModelDict = @{"identifier_0" : "viewModel_0", "identifier_1" : "viewModel_1"};

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// 获取数据的identifer
if (self.viewModelDict[identifier]) {
ViewModel *vm = self.viewModelDict[identifier];
return vm.heigth;
} else {
// calculate viewModel
ViewModel *vm = [ViewModel new];
// calculate cellHeight
vm.height = height;
// calculate label0's frame
vm.label0Frame = label0Frame;
// calculate label1's frame
vm.label1Frame = label1Frame;
self.viewModelDict[identifier] = vm;

return height;
}
}

- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
// 获取数据的identifer
ViewModel *vm = self.viewModelDict[identifier];

label0.text = model.text0;
label0.frame = vm.label0.frame;

label1.text = model.text1;
label1.frame = vm.label1.frame;

// ...

}

当然,上面的ViewModel中并不仅局限于存储height、frame等数据,还可以存储许多布局时需要的数据,比如label对应的text或attributedText等。

总的来说,这种 Cell数据缓存 + MVVM 的方式能够避免很多不必要的重复计算带来的性能开销,很好地提升列表的滚动流畅性;同时将计算布局的代码和实际布局UI的代码拆分开,代码结构更加清晰,并且为之后的进一步优化打好了基础(比如将布局代码放到子线程计算)。

OC 中的 BOOL

Obejctive-C 中的 BOOL 类型定义在 <objc.h>

1
2
3
4
5
6
7
#if (TARGET_OS_IPHONE && __LP64__) || TARGET_OS_WATCH   
#define OBJC_BOOL_IS_BOOL 1
typedef bool BOOL;
#else
#define OBJC_BOOL_IS_CHAR 1
typedef signed char BOOL;
#endif

可见,在32位(准确的说,应该是非64位)机器上,OC中的 BOOL 其实并不是我们熟悉的C语言中的 bool ,而是 signed char 类型,所以OC中的 BOOL 所能存储的数值不止是 0 和 1 ,是 -128~127。

C99提供了_Bool类型,_Bool依然仍是整数类型,但只能赋值为 0 或 1,非 0 值都会被存储为 1 。
C99还提供了<stdbool.h>,其中定义了 bool 代表_Bool,并且定义了 true 和 false,true 代表 1,false 代表 0。

(所以发明 Objective-C 语言那会儿,C也还没有 bool 类型呢)
同时,OC 中还有 YESNO 两个宏,也是在 <objc.h> 中定义,其中 YES 代表 1,NO 代表 0。

1
2
3
4
5
6
7
#if __has_feature(objc_bool)
#define YES __objc_yes
#define NO __objc_no
#else
#define YES ((BOOL)1)
#define NO ((BOOL)0)
#endif

那么考虑下面这种情况,某个函数返回一个 int 值,在 check 函数中通过判断这个函数的返回值来进行之后的操作,那么在32位的机器中运行,check 函数会打印出 YES 还是 NO 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
- (int)returnValue {
// use 256 as an example
return 256;
}

- (void)check {
BOOL yesOrNo = (BOOL)[self returnValue];
if (yesOrNo) {
NSLog(@"YES");
} else {
NSLog(@"NO");
}
}

可能大家觉得想都不用想,肯定是输出 YES。那么实际结果可能会让你恨郁闷,因为输出的是 NO。到底是为什么呢?

还记得文章一开始,我们说32位机器下,OC中的 BOOL 类型实际是 signed char,可以表示-128~127。在执行到 check 方法的第一句时,由于 [self returnValue] 返回的是int类型的数值,所以会进行以下的 int -> signed char(BOOL)类型转换:

BOOL yesOrNo = (BOOL)256;

signed char 占一个字节,也就是8 bit。 256转换成二进制的末16位是 00000001 00000000,强制转换为 signed char 后,取末8位的值 00000000,也就是0。到这里,大家应该意识到,所有末8位全为0的数字,如512、1024…,在32位机器上被强制转换位 BOOL 类型时,都会被转换位 NO(0)。之所以会发生这种“神奇的现象”,是因为32位机器上的 BOOL 类型在“作祟”。
指针类型也一样。继续拿上面的代码举例,这次我们把 returnValue 方法改造下:

1
2
3
4
- (NSString *)returnValue {
NSString *someString = @"xxx";
return someString;
}

如果 someString 为nil,那么没有疑问 check方法会输出 NO。如果 someString 不为nil呢?我们假设 someString 的内存地址为 0xa3b2c100,在将 returnValue 强制转换为 yesOrNo 时,仍然取内存地址的末8位,也就是 0x00,所以yesOrNo的值就是 NO 喽。 当然,只要原字符串的内存地址末8位不全是0,比如0xa3b2c1d0 等,转换后yesOrNo的值就不会为NO


上面说的都是32位机器上才会出现的问题,64位机器上,OC中的BOOL成了真正C语言中的bool,只能存储0和1。所以,在进行类似的类型转换时,只要值不等于0,就会被判为1,不会出现这些问题。但是由于目前iOS仍然需要支持32位的机器,所以平常写代码时还得多多注意。

目前还能见到的32位的iOS设备有:iPhone4s(A5)、iPhone5(A6)、iPhone5c(A6)、iPod Touch5(A5)、iPad3(A5X)、iPad4(A6X)


参考链接: