Alex's Blog

起风了,唯有努力生存


  • Home

  • About

  • Tags

  • Categories

  • Archives

React Native源码分析——Virtual DOM

Posted on 2019-05-13 | Edited on 2019-05-17 | In React Native
Symbols count in article: 27k | Reading time ≈ 50 mins.

本文基于0.58.5分析React Native Reconciliation过程

Components、Elements和Instances

讲Virtual DOM之前,先讲下React Native几个核心概念和这样设计的目的。在面向对象的UI开发时,要渲染一个UI时,都要自己创建UI对象,并且管理对象引用,例如iOS上要渲染一个UIView:

1
2
3
4
UIView *view= [UIView new];
UILabel *label= [UILabel new];
label.text = @"test";
[view addSubview:label];

这种设计模式带来的问题是开发者必须自己创建、更新UI对象,当UI复杂时,维护成本会急剧增加。
React使用一种非常巧妙的设计模式来解决上面问题,UI开发时,只需要描述UI界面,引擎会根据描述自动创建具体的实例,在更新时也只需更新UI界面描述。这样开发者就从复杂的UI对象创建、更新中解放出来,开发者只需要关注UI长怎样和核心逻辑,React帮你搞定对象创建和维护,React通过Components、Elements和Instances来实现这种模式。

Elements

Element是用来描述UI的js对象,Element只有type和props两个属性,Element创建成本很低,一旦创建就不可变,Component更新到时候会创建新的Element。Element可以嵌套,React会递归解析Element直到叶子结点,这样就得到一颗Element树,这颗树就是Virtual DOM树,React通过diff等算法后把Virtual DOM渲染到屏幕,渲染过程做了很多优化。可以通过render()或者其他方法返回Element,例如:

1
2
3
4
5
6
7
render() {
return(
<View style = {{height:200,backgroundColor:'#999999'}}>
<Text> test </Text>
</View>
)
}

上述JSX语法最终会转换成以下Element:

1
2
3
4
5
6
7
8
9
10
11
12
{
type:View,
props: {
style:{height:200,backgroundColor:'#999999'},
children: {
type:Text,
props: {
children:'test'
}
}
}
}

Components

Component是生成Element的对象,可以是个class,也可以是简单的方法。当Component是class的时候,可以存储state和其他属性,实现复杂的逻辑;当Component是方法的时候,是不可变的Component,相当于只有render()方法的class Component。

Instances

Instance就是Component 实例,React Native开发过程不用自己管理Instance,React引擎自动创建并维护Instance,具体创建逻辑下文详细介绍。

Virtual DOM

通常所说的Virtual DOM是指相对于Real DOM的element树,Virtual DOM是最初React版本的说法,最开始React只是用在前端,引入Virtual DOM的概念是为了提升UI渲染性能,在UI变化的时候可以先比较Virtual DOM,只更新有变化的Real DOM。

Reconciliation

React之所以有这么好的渲染性能,主要是因为在UI变化的时候可以先比较Virtual DOM,只更新有变化的Real DOM,整个更新过程叫Reconciliation。

Fiber

React 16重构了Reconciliation 实现,新框架叫Fiber,Facebook团结花了两年时间实现Fiber,核心优化就是使用Vitual Stack的概念。Fiber之前更新UI的时候是通过同步递归的方式遍历Virtual DOM树,整个过程是同步的,并且在遍历结束之前无法中断,这样在动画的时候就可能导致卡顿。Fiber使用Vitual Stack的概念,把同步递归操作分解成一个个异步、可中断的操作单元,从而解决卡顿问题,并且随时可以取消不需要的操作。

UI更新

React Native UI更新主要可以分为以下两个阶段:

  • Render
  • Commit

Render过程计算新的element树,render()方法在这个阶段调用的;Commit过程调用diff算法,更新实际发生变化的UI。
Render 阶段核心方法调用顺序:

  • componentWillReceiveProps
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render()
  • reconcileChildren()

Commit 阶段核心方法调用顺序:

  • getSnapshotBeforeUpdate
  • diff
  • updateView
  • componentDidUpdate

componentWillReceiveProps和componentWillUpdate已经废弃,不推荐使用了,使用getDerivedStateFromProps和getSnapshotBeforeUpdate代替
updateView方法调用RCTUIManager.updateView更新Native View。

Reconciliation源码分析

接下来以setState方法为切入点分析,分析React Native Reconciliation过程,主要分析UI更新过程,并不深入Fiber细节。

setState

1
2
3
4
5
6
7
8
9
  _callback() {
console.log('callback');
}

_onPress1() {
this.setState(previousState => (
{ value: previousState.value+1}
), this._callback.bind(this));
}

上面代码是React Native上更新UI的最常用方法,我们知道setState是异步调用的,但state是什么时机更新?callback又什么时机调用呢?又是怎么触发Virtual DOM树和UI更新的呢?

1
2
3
4
5
//react.development.js:333
Component.prototype.setState = function (partialState, callback) {
!(typeof partialState === 'object' || typeof partialState === 'function' || partialState == null) ? invariant(false, 'setState(...): takes an object of state variables to update or a function which returns an object of state variables.') : void 0;
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//ReactNativeRender-dev.js:8456
var classComponentUpdater = {
isMounted: isMounted,
enqueueSetState: function(inst, payload, callback) {
var fiber = get$1(inst);
var currentTime = requestCurrentTime();
var expirationTime = computeExpirationForFiber(currentTime, fiber);

var update = createUpdate(expirationTime);
update.payload = payload;
if (callback !== undefined && callback !== null) {
{
warnOnInvalidCallback(callback, "setState");
}
update.callback = callback;
}

flushPassiveEffects();
enqueueUpdate(fiber, update);
scheduleWork(fiber, expirationTime);
}
...
}

React可以用在Web、Node、React Native,底层updater指向具体实现,React Native上就是classComponentUpdater。
可以看到setState最终会创建一个update结构,其中payload就是更新state的匿名方法,然后插入队列,payload和callback将在后面异步执行。

element树更新

前文说过Render过程计算新的element树,render()方法在这个阶段调用的,先看一下函数调用栈:

Render阶段函数调用栈

通过函数名可以猜测更新过程会把异步处理批量更新,这样可以提高性能,接下来分析Render过程核心方法。

performWorkOnRoot

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
//ReactNativeRender-dev.js:17168
function performWorkOnRoot(root, expirationTime, isYieldy) {
// Check if this is async work or sync/expired work.
if (!isYieldy) {
// Flush work without yielding.
// TODO: Non-yieldy work does not necessarily imply expired work. A renderer
// may want to perform some work without yielding, but also without
// requiring the root to complete (by triggering placeholders).

var finishedWork = root.finishedWork;
if (finishedWork !== null) {
// This root is already complete. We can commit it.
completeRoot(root, finishedWork, expirationTime);
} else {
root.finishedWork = null;
// If this root previously suspended, clear its existing timeout, since
// we're about to try rendering again.
var timeoutHandle = root.timeoutHandle;
if (timeoutHandle !== noTimeout) {
root.timeoutHandle = noTimeout;
// $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above
cancelTimeout(timeoutHandle);
}
renderRoot(root, isYieldy);
finishedWork = root.finishedWork;
if (finishedWork !== null) {
// We've completed the root. Commit it.
completeRoot(root, finishedWork, expirationTime);
}
}
}
...
}

performWorkOnRoot是UI更新的入口方法,React Native上isYieldy直接传的false。每次更新的时候都会从最顶端的节点开始计算新的element树,不管是哪个节点调的setState,但没变化的节点并不会重新计算,而是直接重用。但如果父节点发生变化,则所有字节的都会进行重新计算,而不管子节点是否变化,除非子节点shouldComponentUpdate返回false,或者子节点是PureReactComponent。
renderRoot就是Render阶段入口方法,completeRoot则是Commit阶段入口方法。

workLoop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//ReactNativeRender-dev.js:16111
function workLoop(isYieldy) {
if (!isYieldy) {
// Flush work without yielding
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
} else {
// Flush asynchronous work until there's a higher priority event
while (nextUnitOfWork !== null && !shouldYieldToRenderer()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
}
}

workLoop方法就是遍历整颗element树,React 16重构了Reconciliation 实现,新框架叫Fiber,Fiber使用Vitual Stack的概念,把同步递归操作分解成一个个异步、可中断的操作单元,每个操作单元就是一个节点计算过程。
performUnitOfWork就是具体节点计算,每次执行完会通过深度优先返回下一个需要执行的节点,这样就可以遍历整个节点树了。

performUnitOfWork

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//ReactNativeRender-dev.js:16049
function performUnitOfWork(workInProgress) {
// The current, flushed, state of this fiber is the alternate.
// Ideally nothing should rely on this, but relying on it here
// means that we don't need an additional field on the work in
// progress.
var current$$1 = workInProgress.alternate;
...
var next = void 0;
if (enableProfilerTimer) {
if (workInProgress.mode & ProfileMode) {
startProfilerTimer(workInProgress);
}
next = beginWork(current$$1, workInProgress, nextRenderExpirationTime);
workInProgress.memoizedProps = workInProgress.pendingProps;
...
if (next === null) {
// If this doesn't spawn new work, complete the current work.
next = completeUnitOfWork(workInProgress);
}
ReactCurrentOwner$2.current = null;
return next;
}

performUnitOfWork主要就是调用beginWork方法,然后更新props。

beginWork
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
//ReactNativeRender-dev.js:12601
function beginWork(current$$1, workInProgress, renderExpirationTime) {
var updateExpirationTime = workInProgress.expirationTime;

if (current$$1 !== null) {
var oldProps = current$$1.memoizedProps;
var newProps = workInProgress.pendingProps;
if (
oldProps === newProps &&
!hasContextChanged() &&
updateExpirationTime < renderExpirationTime
) {
// This fiber does not have any pending work. Bailout without entering
// the begin phase. There's still some bookkeeping we that needs to be done
// in this optimized path, mostly pushing stuff onto the stack.
switch (workInProgress.tag) {
...
case ClassComponent: {
var Component = workInProgress.type;
if (isContextProvider(Component)) {
pushContextProvider(workInProgress);
}
break;
}
...
return bailoutOnAlreadyFinishedWork(
current$$1,
workInProgress,
renderExpirationTime
);
}
}

// Before entering the begin phase, clear the expiration time.
workInProgress.expirationTime = NoWork;

switch (workInProgress.tag) {
...
case ClassComponent: {
var _Component2 = workInProgress.type;
var _unresolvedProps = workInProgress.pendingProps;
var _resolvedProps =
workInProgress.elementType === _Component2
? _unresolvedProps
: resolveDefaultProps(_Component2, _unresolvedProps);
return updateClassComponent(
current$$1,
workInProgress,
_Component2,
_resolvedProps,
renderExpirationTime
);
}
...
}
}

可以看到beginWork主要是两个分支,每个分支都是一个很大switch case语句,第一个分支处理节点没变化的情况,这个时候不会进行计算,第二个分支处理节点发生变化的情况。每个switch case处理不同类型的节点,这里只分析ClassComponent类型。
最终会调用updateClassComponent方法更新发生变化的节点。

updateClassComponent

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
//ReactNativeRender-dev.js:11457
function updateClassComponent(
current$$1,
workInProgress,
Component,
nextProps,
renderExpirationTime
) {
{
...
var instance = workInProgress.stateNode;
var shouldUpdate = void 0;
if (instance === null) {
if (current$$1 !== null) {
// An class component without an instance only mounts if it suspended
// inside a non- concurrent tree, in an inconsistent state. We want to
// tree it like a new mount, even though an empty version of it already
// committed. Disconnect the alternate pointers.
current$$1.alternate = null;
workInProgress.alternate = null;
// Since this is conceptually a new fiber, schedule a Placement effect
workInProgress.effectTag |= Placement;
}
// In the initial pass we might need to construct the instance.
constructClassInstance(
workInProgress,
Component,
nextProps,
renderExpirationTime
);
mountClassInstance(
workInProgress,
Component,
nextProps,
renderExpirationTime
);
shouldUpdate = true;
} else if (current$$1 === null) {
// In a resume, we'll already have an instance we can reuse.
shouldUpdate = resumeMountClassInstance(
workInProgress,
Component,
nextProps,
renderExpirationTime
);
} else {
shouldUpdate = updateClassInstance(
current$$1,
workInProgress,
Component,
nextProps,
renderExpirationTime
);
}
var nextUnitOfWork = finishClassComponent(
current$$1,
workInProgress,
Component,
shouldUpdate,
hasContext,
renderExpirationTime
);
...
return nextUnitOfWork;
}

updateClassComponent会判断Component是否实例化,如果没有实例化的话会创建Component实例,这也是前文说的React引擎自动创建Instance的时机,如果已经实例化则调用updateClassInstance更新实例,updateClassInstance会返回该实例是否真正需要更新,并更新props和state。最后会调用finishClassComponent更新element并返回下一个计算单元。

updateClassInstance

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//ReactNativeRender-dev.js:9282
// Invokes the update life-cycles and returns false if it shouldn't rerender.
function updateClassInstance(
current,
workInProgress,
ctor,
newProps,
renderExpirationTime
) {
var instance = workInProgress.stateNode;

var oldProps = workInProgress.memoizedProps;
instance.props =
workInProgress.type === workInProgress.elementType
? oldProps
: resolveDefaultProps(workInProgress.type, oldProps);

var oldContext = instance.context;
var contextType = ctor.contextType;
var nextContext = void 0;
if (typeof contextType === "object" && contextType !== null) {
nextContext = readContext$1(contextType);
} else {
var nextUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true);
nextContext = getMaskedContext(workInProgress, nextUnmaskedContext);
}

var getDerivedStateFromProps = ctor.getDerivedStateFromProps;
var hasNewLifecycles =
typeof getDerivedStateFromProps === "function" ||
typeof instance.getSnapshotBeforeUpdate === "function";

// Note: During these life-cycles, instance.props/instance.state are what
// ever the previously attempted to render - not the "current". However,
// during componentDidUpdate we pass the "current" props.

// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for components using the new APIs.
if (
!hasNewLifecycles &&
(typeof instance.UNSAFE_componentWillReceiveProps === "function" ||
typeof instance.componentWillReceiveProps === "function")
) {
if (oldProps !== newProps || oldContext !== nextContext) {
callComponentWillReceiveProps(
workInProgress,
instance,
newProps,
nextContext
);
}
}

resetHasForceUpdateBeforeProcessing();

//调用setState匿名方法更新state
var oldState = workInProgress.memoizedState;
var newState = (instance.state = oldState);
var updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
processUpdateQueue(
workInProgress,
updateQueue,
newProps,
instance,
renderExpirationTime
);
newState = workInProgress.memoizedState;
}

if (
oldProps === newProps &&
oldState === newState &&
!hasContextChanged() &&
!checkHasForceUpdateAfterProcessing()
) {
// If an update was already in progress, we should schedule an Update
// effect even though we're bailing out, so that cWU/cDU are called.
if (typeof instance.componentDidUpdate === "function") {
if (
oldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.effectTag |= Update;
}
}
if (typeof instance.getSnapshotBeforeUpdate === "function") {
if (
oldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.effectTag |= Snapshot;
}
}
return false;
}

if (typeof getDerivedStateFromProps === "function") {
applyDerivedStateFromProps(
workInProgress,
ctor,
getDerivedStateFromProps,
newProps
);
newState = workInProgress.memoizedState;
}

var shouldUpdate =
checkHasForceUpdateAfterProcessing() ||
checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext
);

if (shouldUpdate) {
// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for components using the new APIs.
if (
!hasNewLifecycles &&
(typeof instance.UNSAFE_componentWillUpdate === "function" ||
typeof instance.componentWillUpdate === "function")
) {
startPhaseTimer(workInProgress, "componentWillUpdate");
if (typeof instance.componentWillUpdate === "function") {
instance.componentWillUpdate(newProps, newState, nextContext);
}
if (typeof instance.UNSAFE_componentWillUpdate === "function") {
instance.UNSAFE_componentWillUpdate(newProps, newState, nextContext);
}
stopPhaseTimer();
}
if (typeof instance.componentDidUpdate === "function") {
workInProgress.effectTag |= Update;
}
if (typeof instance.getSnapshotBeforeUpdate === "function") {
workInProgress.effectTag |= Snapshot;
}
} else {
// If an update was already in progress, we should schedule an Update
// effect even though we're bailing out, so that cWU/cDU are called.
if (typeof instance.componentDidUpdate === "function") {
if (
oldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.effectTag |= Update;
}
}
if (typeof instance.getSnapshotBeforeUpdate === "function") {
if (
oldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.effectTag |= Snapshot;
}
}

// If shouldComponentUpdate returned false, we should still update the
// memoized props/state to indicate that this work can be reused.
workInProgress.memoizedProps = newProps;
workInProgress.memoizedState = newState;
}

// Update the existing instance's state, props, and context pointers even
// if shouldComponentUpdate returns false.
instance.props = newProps;
instance.state = newState;
instance.context = nextContext;

return shouldUpdate;
}

updateClassInstance判断节点是否需要跟新,调用以下life-cycles方法:

  • componentWillReceiveProps
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • componentWillUpdate

前文已经知道调用setState时会创建一个update结构,updateClassInstance 会调用processUpdateQueue方法计算新的state,processUpdateQueue方法里面会调用setState传的匿名函数

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
//ReactNativeRender-dev.js:8517
function checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext
) {
var instance = workInProgress.stateNode;
if (typeof instance.shouldComponentUpdate === "function") {
startPhaseTimer(workInProgress, "shouldComponentUpdate");
var shouldUpdate = instance.shouldComponentUpdate(
newProps,
newState,
nextContext
);
stopPhaseTimer();
{
!(shouldUpdate !== undefined)
? warningWithoutStack$1(
false,
"%s.shouldComponentUpdate(): Returned undefined instead of a " +
"boolean value. Make sure to return true or false.",
getComponentName(ctor) || "Component"
)
: void 0;
}
return shouldUpdate;
}
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return (
!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
);
}
return true;
}

updateClassInstance调用checkShouldComponentUpdate判断是否需要更新,checkShouldComponentUpdate实现比较简单,

  1. 如果Component实现shouldComponentUpdate方法,则调用shouldComponentUpdate;
  2. 如果是PureReactComponent,则调用shallowEqual比较props和state是否变化;
  3. 否则返回true

这里要注意checkShouldComponentUpdate默认返回true,所以只要父节点更新,默认就会更新所有子节点,这就是为什么可以通过shouldComponentUpdate返回false或使用PureReactComponent来提升性能。

finishClassComponent

Render阶段最后就是调用finishClassComponent方法计算新的element,并且调用reconcileChildren遍历子节点,具体代码如下:

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
//ReactNativeRender-dev.js:11562
function finishClassComponent(
current$$1,
workInProgress,
Component,
shouldUpdate,
hasContext,
renderExpirationTime
) {
...
if (!shouldUpdate && !didCaptureError) {
// Context providers should defer to sCU for rendering
if (hasContext) {
invalidateContextProvider(workInProgress, Component, false);
}

return bailoutOnAlreadyFinishedWork(
current$$1,
workInProgress,
renderExpirationTime
);
}

var instance = workInProgress.stateNode;

// Rerender
ReactCurrentOwner$3.current = workInProgress;
var nextChildren = void 0;
...
setCurrentPhase("render");
nextChildren = instance.render();
...
reconcileChildren(
current$$1,
workInProgress,
nextChildren,
renderExpirationTime
);

// Memoize state using the values we just used to render.
// TODO: Restructure so we never read values from the instance.
workInProgress.memoizedState = instance.state;
...
return workInProgress.child;
}

如果shouldUpdate为false,则直接重用现有节点,跟beginWork处理没变化的节点一样。
如果shouldUpdate为true,则调用render方法计算新的element,然后调用reconcileChildren遍历子节点。

completeUnitOfWork

在遍历到叶子节点后performUnitOfWork会调用completeUnitOfWork

1
2
3
4
5
6
7
8
9
//ReactNativeRender-dev.js:16101
function performUnitOfWork(workInProgress) {
...
if (next === null) {
// If this doesn't spawn new work, complete the current work.
next = completeUnitOfWork(workInProgress);
}
...
}

completeUnitOfWork调用completeWork标记需要更新的节点,如果有兄弟节点则返回兄弟节点,继续遍历兄弟节点,否则标记父节点。

Commit 阶段源码分析

前文分析来Render阶段核心方法,Render阶段会生成一颗新的element树,并且生成一个Effect list,Effect list是一个线性列表,包含真正需要更新的操作,Commit 阶段则通过Effect list更新具体的UI,首先看下Commit 阶段的函数调用栈:

Commit阶段函数调用栈

commitAllHostEffects

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
//ReactNativeRender-dev.js:15349
function commitAllHostEffects() {
while (nextEffect !== null) {
{
setCurrentFiber(nextEffect);
}
recordEffect();

var effectTag = nextEffect.effectTag;

if (effectTag & ContentReset) {
commitResetTextContent(nextEffect);
}

if (effectTag & Ref) {
var current$$1 = nextEffect.alternate;
if (current$$1 !== null) {
commitDetachRef(current$$1);
}
}

// The following switch statement is only concerned about placement,
// updates, and deletions. To avoid needing to add a case for every
// possible bitmap value, we remove the secondary effects from the
// effect tag and switch on that value.
var primaryEffectTag = effectTag & (Placement | Update | Deletion);
switch (primaryEffectTag) {
case Placement: {
commitPlacement(nextEffect);
// Clear the "placement" from effect tag so that we know that this is inserted, before
// any life-cycles like componentDidMount gets called.
// TODO: findDOMNode doesn't rely on this any more but isMounted
// does and isMounted is deprecated anyway so we should be able
// to kill this.
nextEffect.effectTag &= ~Placement;
break;
}
case PlacementAndUpdate: {
// Placement
commitPlacement(nextEffect);
// Clear the "placement" from effect tag so that we know that this is inserted, before
// any life-cycles like componentDidMount gets called.
nextEffect.effectTag &= ~Placement;

// Update
var _current = nextEffect.alternate;
commitWork(_current, nextEffect);
break;
}
case Update: {
var _current2 = nextEffect.alternate;
commitWork(_current2, nextEffect);
break;
}
case Deletion: {
commitDeletion(nextEffect);
break;
}
}
nextEffect = nextEffect.nextEffect;
}

{
resetCurrentFiber();
}
}

commitAllHostEffects源码比较好理解,循环执行effect操作,effect操作可能是替换、删除、更新等

commitWork

更新操作会调用commitWork,源码如下:

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
//ReactNativeRender-dev.js:14628
function commitWork(current$$1, finishedWork) {
...
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
// Note: We currently never use MountMutation, but useLayout uses
// UnmountMutation.
commitHookEffectList(UnmountMutation, MountMutation, finishedWork);
return;
}
case ClassComponent: {
return;
}
case HostComponent: {
var instance = finishedWork.stateNode;
if (instance != null) {
// Commit the work prepared earlier.
var newProps = finishedWork.memoizedProps;
// For hydration we reuse the update path but we treat the oldProps
// as the newProps. The updatePayload will contain the real change in
// this case.
var oldProps =
current$$1 !== null ? current$$1.memoizedProps : newProps;
var type = finishedWork.type;
// TODO: Type the updateQueue to be specific to host components.
var updatePayload = finishedWork.updateQueue;
finishedWork.updateQueue = null;
if (updatePayload !== null) {
commitUpdate(
instance,
updatePayload,
type,
oldProps,
newProps,
finishedWork
);
}
}
return;
}
case HostText: {
invariant(
finishedWork.stateNode !== null,
"This should have a text node initialized. This error is likely " +
"caused by a bug in React. Please file an issue."
);
var textInstance = finishedWork.stateNode;
var newText = finishedWork.memoizedProps;
// For hydration we reuse the update path but we treat the oldProps
// as the newProps. The updatePayload will contain the real change in
// this case.
var oldText = current$$1 !== null ? current$$1.memoizedProps : newText;
commitTextUpdate(textInstance, oldText, newText);
return;
}
...
}
}

React Native上Component都是由View、Text等基础Component组成的,所以最终实际更新的是View、Text等基础Component,最后会调用commitUpdate和commitTextUpdate完成实际的更新操作。

commitUpdate

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
//ReactNativeRender-dev.js:4153
function commitUpdate(
instance,
updatePayloadTODO,
type,
oldProps,
newProps,
internalInstanceHandle
) {
var viewConfig = instance.viewConfig;

updateFiberProps(instance._nativeTag, newProps);

var updatePayload = diff(oldProps, newProps, viewConfig.validAttributes);

// Avoid the overhead of bridge calls if there's no update.
// This is an expensive no-op for Android, and causes an unnecessary
// view invalidation for certain components (eg RCTTextInput) on iOS.
if (updatePayload != null) {
UIManager.updateView(
instance._nativeTag, // reactTag
viewConfig.uiViewClassName, // viewName
updatePayload // props
);
}
}

commitUpdate源码比较好理解,首先调用diff判断是否需要更新,如果需要更新的话调用UIManager.updateView更新Native UI,其中UIManager.updateView是Native 暴露的module。diff方法主要是比较props是否变化:

1
2
3
4
5
6
7
8
9
//ReactNativeRender-dev.js:3638
function diff(prevProps, nextProps, validAttributes) {
return diffProperties(
null, // updatePayload
prevProps,
nextProps,
validAttributes
);
}

commitTextUpdate

1
2
3
4
5
6
7
8
9
//ReactNativeRender-dev.js:14659
//ReactNativeRender-dev.js:4145
function commitTextUpdate(textInstance, oldText, newText) {
UIManager.updateView(
textInstance, // reactTag
"RCTRawText", // viewName
{ text: newText } // props
);
}

commitTextUpdate实现比较简单,因为completeWork方法标记TextComponent的时候已经判断了text是否变化,所以直接调用UIManager.updateView。

life-cycles方法调用

在更新完UI后将调用componentDidUpdate方法和setState callback方法,具体调用栈如下:
componentDidUpdate调用栈

setState callback调用栈

总结

React Native Reconciliation过程比较复杂,Fiber框架把递归操作分解成一个个异步、可中断的操作单元后进一步复杂度。
Reconciliation主要可以分为以下两个阶段:

  • Render
  • Commit

Render阶段从根节点开始遍历element树,对于不需要更新的节点直接重用fiber node,对于需要更新的节点,调用life-cycle方法,然后调用render方法计算新的element,最后调用reconcileChildren遍历子节点。
Render阶段还会标记更新,并且生成一个Effect list,Effect list是一个线性列表,包含真正需要更新的操作。
Commit 阶段通过Effect list更新具体的UI,这阶段会调用diff,然后调用UIManager.updateView更新Native View,最后调用componentDidUpdate方法和setState callback方法。

#引用

Inside Fiber: in-depth overview of the new reconciliation algorithm in React
React Fiber Architecture
React Components, Elements, and Instances
Reconciliation

objc_msgSend汇编源码分析

Posted on 2019-05-13 | Edited on 2019-05-17 | In ios
Symbols count in article: 4k | Reading time ≈ 7 mins.

引言

Objective-C是通过消息机制调用方法的,编译器会把所有消息发送转为objc_msgSend方法调用。说到objc_msgSend的汇编实现,大多数人会觉的是因为性能高才用汇编实现,几乎没有文章说其它原因。Objective-C所有方法都会转为objc_msgSend方法调用,然而每个方法参数和返回值都可能不一样,参数和返回值要怎么处理?

  • 本文首先会结合Objective-C Runtime机制深入分析objc_msgSend汇编实现。
  • 本文最后会从Calling Conventions角度分析objc_msgSend实现,利用Calling Conventions和汇编还可以实现很多黑科技。

Objective-C对象结构

Objective-C中消息发送核心数据结构如下:

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
//以下代码均为arm64平台
typedef struct objc_class *Class;
typedef struct objc_object *id;

struct objc_object {
isa_t isa;
}

struct objc_class : objc_object {
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits; //class_rw_t*
}

@interface NSObject <NSObject> {
Class isa OBJC_ISA_AVAILABILITY;
}

union isa_t
{
Class cls;
uintptr_t bits;
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
struct {
uintptr_t nonpointer : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 19;
};
}

struct cache_t {
struct bucket_t *_buckets;
mask_t _mask;
mask_t _occupied;
}

struct bucket_t {
cache_key_t _key;//实际上是selector
IMP _imp; //实际上是函数指针
}

struct class_rw_t {
uint32_t flags;
uint32_t version;
const class_ro_t *ro;
method_array_t methods;
property_array_t properties;
protocol_array_t protocols;
Class firstSubclass;
Class nextSiblingClass;
char *demangledName;
#if SUPPORT_INDEXED_ISA
uint32_t index;
#endif
}

NSObject子类的实例都有个isa指针,isa指向Class,Class有superclass、cache、实例方法、属性、protocol等Runtime信息,调用实例方法的时候就是通过isa指针找到Class,然后找到IMP调用实际的方法。
Class本身也是一个对象,也有isa指针,指向meta-class,meta-class也是一个对象,有类方法等属性,调用类方法的时候,就是通过Class对象的isa指针找到meta-class,然后找到IMP调用实际的方法。
实例、Class、meta-class关系如下图,图片来源:

对象关系图

消息机制

当编译器遇到一个方法调用时,它会将方法的调用翻译成以下函数中的一个,
objc_msgSend、 objc_msgSend_stret、 objc_msgSendSuper 和 objc_msgSendSuper_stret。

  • 发送给对象的父类的消息会使用 objc_msgSendSuper ;
  • 有数据结构作为返回值的方法会使用 objc_msgSendSuper_stret 或 objc_msgSend_stret ;
  • 其它的消息都是使用 objc_msgSend 发送的。

objc_msgSend查找selector的IMP,然后调用实际的方法,主要包括以下流程:

  1. 查看cache是否有selector的IMP,如果有的话直接调用
  2. 如果没cache,最终会调用lookUpImpOrForward,从类方法列表查找IMP并缓存到cache
  3. 如果方法列表没有则会查找基类的方法,直到最上层基类(查找基类的时候也是先查找缓存,再查找方法列表)
  4. 如果基类也没查找到,则返回_objc_msgForward的IMP,走消息转发流程。

我们也可以自己通过class_getMethodImplementation拿到方法IMP(IMP是实际方法的函数指针),然后调用:

1
2
3
//[view addSubview:view2]
void (*funtion_pointer)(id, SEL, UIView*) = (void(*)(id, SEL, UIView*)) class_getMethodImplementation((id)view, @selector(addSubview:));
funtion_pointer(view, @selector(addSubview:), view2)

汇编源码

objc_msgSend汇编源码在Messengers.subproj目录,具体汇编如下:

objc_msgSend汇编源码

_objc_msgSend_uncached汇编源码

objc_msgSend汇编代码不长,结合objc源码比较容易看懂。需要注意的是isa和TaggedPointer格式,isa指针不是纯粹的指针,还保存很多其它信息,具体可以参考isa_t union定义,其中只有3到35位才是class指针,所以查找之前会通过mask转换成class指针。

isa格式

iOS系统为了提高性能和减小内存,使用了TaggedPointer来表示NSNumber、NSIndexPath等对象,对象并没有分配内存空间,而是把对象值保存在指针里面,只有指针无法容纳对象才会分配实际内存。TaggedPointer具体格式如下图,tag index表示具体Class,系统有维护一个全局映射表来保存tag index和Class的关系,具体可以查看objc_tag_index_t定义,查找到具体Class之后就跟正常oc对象一样查找IMP了。

TaggedPointer指针格式

Calling Conventions

arm64架构是通过q0-q7和x0-x7来传函数参数,可以看到objc_msgSend没对这几个寄存器做任何操作,找到IMP后直接通过br x17调用IMP,br告诉cpu不是子程序调用。
Objective-C所以方法发送都是通过objc_msgSend,每个方法返回值和参数都不一样,如果objc_msgSend像普通函数一样处理参数,为了处理不同参数类型和参数个数,可以使用varargs ,Objective-C调用的地方必须包裹成varargs,这样处理非常不灵活,objc_msgSend用了个很巧妙的技巧,就是对参数不做任何处理,查找到IMP后直接调用,因为在objc_msgSend开始执行时,栈帧(stack frame)的状态、数据,和各个寄存器的组合形式、数据,跟调用具体的函数指针(IMP)时所需的状态、数据,是完全一致的,所以我们用xcode调试的时候函数栈是看不到objc_msgSend,看上去就是消息发送过程完全没发生过,跟调用普通的c方法一摸一样。

黑科技

objc_msgSend用很巧妙的技巧处理参数问题,利用这种技巧可以做很多方法,比如可以实现Aspects的效果,在调用实际方法前做些hook操作,hook完后再调实际方法。也可以使用libffi处理参数问题,可以搞很多事情。

#引用

Why objc_msgSend Must be Written in Assembly
面向切面 Aspects 源码阅读
What is a meta-class in Objective-C?
Objective-C 中的消息与消息转发

深入理解Core Text排版引擎

Posted on 2019-05-13 | Edited on 2019-05-17 | In ios
Symbols count in article: 5.3k | Reading time ≈ 10 mins.

iOS系统上可以使用UILable、UITextFileld、TextKit显示文本,TextKit也可以做一些布局控制,但如果需要精细的布局控制,或者自线程异步绘制文本,就必须使用Core Text和Core Graphics,本文比较系统地讲解Core Text排版核心概念。

iOS文本系统框架

iOS文本系统框架

Core Text是iOS 系统文本排版核心框架,TextKit和WebKit都是封装在CoreText上的,TextKit是iOS7引入的,在iOS7之前几乎所有的文本都是 WebKit 来处理的,包括UILable、UITextFileld等,TextKit是从Cocoa文本系统移植到iOS系统的。
文本渲染过程中Core Text只负责排版,具体的绘制操作是通过Core Graphics框架完成的。如果需要精细的排版控制可以使用Core Text,否则可以直接使用Text Kit。

Core Text排版引擎框架

CoreText排版引擎框架

CTFramesetter是Core Text中最上层的类,CTFramesetter持有attributed string并创建CTTypesetter,实际排版由CTTypesetter完成。CTFrame类似于书本中的「页」,CTLine类似「行」,CTRun是一行中具有相同属性的连续字形。CTFrame、CTLine、CTRun都有对应的Draw方法绘制文本,其中CTRun支持最精细的控制。

CoreText排版引擎框架

排版核心概念

要实现精细的排版控制,就必须理解排版的概念,因为Core Text很多api都涉及到排版概念,这些概念是平台无关的,其他系统也一样适应。
排版引擎通过下面两步对文本进行排版:

  • 生成字形(glyph generation)
  • 字形布局(glyph layout)

字符(Characters)和字形(Glyphs)

字符和字形概念比较好理解,下图很直观
Glyphs of the character A

字型(Typefaces)和字体(Fonts)

字型和字体的概念可能没这么好区分,直接引用官方文档的原话

A typeface is a set of visually related shapes for some or all of the characters in a written language.

A font is a series of glyphs depicting the characters in a consistent size, typeface, and typestyle.

字体是字型的子集,字型也叫font family,比如下图:

Fonts in the Times font family

字体属性(Font metrics)

排版引擎要布局字型,就必须知道字型大小和怎样布局,这些信息就叫字体属性,开发过程也是通过这些属性来计算布局的。字体属性由字体设计者提供,同一个字体不同字形的属性相同,主要属性如下图:

Glyph metrics

Glyph metrics

  • baseline:字符基线,baseline是虚拟的线,baseline让尽可能多的字形在baseline上面,CTFrameGetLineOrigins获取的Origins就是每一行第一个CTRun的Origin
  • ascent:字形最高点到baseline的推荐距离
  • descent:字形最低点到baseline的推荐距离
  • leading:行间距,即前一行的descent与下一行的ascent之间的距离
  • advance width:Origin到下一个字形Origin的距离
  • left-side bearing:Origin到字形最左边的距离
  • right-side bearing:字形最右边到下一个字形Origin的距离
  • bounding box:包含字形最小矩形
  • x-height:一般指小写字母x最高的到baseline的推荐距离
  • Cap-height:一般指H或I最高的到baseline的推荐距离

部分字体属性可以通过UIFont的方法获取,ascent、descent、leading可以通过CTRunGetTypographicBounds、CTRunGetTypographicBounds方法获取,通过ascent、descent、leading可以计算line的实际高度。CTFrame、CTLine、CTRun都有提供api获取属性和绘制文本,控制粒度也由高到低,可以根据具体需求使用不同的粒度。
CTLine上不同CTRun默认是底部对齐的,如果一行文本有Attachment,并且Attachment比字体高,会导致字符偏下,如下图:

带有Attachment的文本渲染

如果要使字符居中对齐,可以通过CGContextSetTextPosition调整每个CTRun的originY,调整后如下图:

垂直方向居中

字距调整

排版系统默认按advance width逐个字符渲染,这样会导致有些字符间距离很大,为了使排版后可读性更高,一般会调整字距,如下图:

Kerning

坐标系变换

UIKit和Core Graphics使用不同的坐标系,UIKit坐标系原点在左上角,Core Graphics坐标系在左下角,如下图:

Core Graphics和UIKit坐标系

使用Core Graphics绘制前必须进行坐标变换,否则绘制后的文本是倒立的,
如下图:

坐标未变换

坐标一般通过下面方法进行变换:

1
2
3
4
5
6
//1.设置字形的变换矩阵为不做图形变换
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
//2.平移方法,将画布向上平移bounds的高度
CGContextTranslateCTM(context, 0.0f, self.bounds.size.height);
//3.缩放方法,x轴缩放系数为1,则不变,y轴缩放系数为-1,则相当于以x轴为轴旋转180度
CGContextScaleCTM(context, 1.0f, -1.0f);

变换之后就将Core Graphics坐标系变换成UIKit坐标系了。

Attachment

Core Text 不能直接绘制图像,但可以留出空白空间来为图像腾出空间。通过设置 CTRun 的 delegate,可以确定 CTRun 的 ascent space, descent space and width,如下图:

Attachment渲染

当Core Text遇到一个设置了CTRunDelegate的CTRun,它就会询问delegate:“我需要留多少空间给这块的数据”。通过在CTRunDelegate中设置这些属性,您可以在文本中给图片留开空位。具体方法可以参考「 Core Text Tutorial for iOS: Making a Magazine App 」

点击响应

使用文本渲染的时候经常需要不同的文本响应不同的点击事件,Core Text本身是不支持点击事件的,要实现不同的文本响应不同的点击事件,就必须知道点击的是哪个字符,核心过程:

  • 重写UIView Touch Event方法捕捉点击事件
  • 通过Core Text查找touch point对应的字符

这里主要讲下如何通过Core Text查找touch point对应的字符,核心代码如下:

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
- (CFIndex)characterIndexAtPoint:(CGPoint)p {
CFIndex idx = NSNotFound;
if (!_lines) {
return idx;
}
CGPoint *linesOrigins = (CGPoint*)malloc(sizeof(CGPoint) * CFArrayGetCount(_lines));
if (!linesOrigins) {
return idx;
}

p = CGPointMake(p.x - _rect.origin.x, p.y - _rect.origin.y);
// Convert tap coordinates (start at top left) to CT coordinates (start at bottom left)
p = CGPointMake(p.x, _rect.size.height - p.y);

CTFrameGetLineOrigins(_frame, CFRangeMake(0, 0), linesOrigins);

for (CFIndex lineIndex = 0; lineIndex < _fitNumberOfLines; lineIndex++) {
CGPoint lineOrigin = linesOrigins[lineIndex];
CTLineRef line = CFArrayGetValueAtIndex(_lines, lineIndex);

// Get bounding information of line
CGFloat ascent = 0.0f, descent = 0.0f, leading = 0.0f;
CGFloat width = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
CGFloat yMin = (CGFloat)floor(lineOrigin.y - descent);
CGFloat yMax = (CGFloat)ceil(lineOrigin.y + ascent);

// Apply penOffset using flushFactor for horizontal alignment to set lineOrigin since this is the horizontal offset from drawFramesetter
CGFloat flushFactor = 0.0;;
CGFloat penOffset = (CGFloat)CTLineGetPenOffsetForFlush(line, flushFactor, _rect.size.width);
lineOrigin.x = penOffset;
lineOrigin.y = lineOrigin.y - _originY;

// Check if we've already passed the line
if (p.y > yMax) {
break;
}
// Check if the point is within this line vertically
if (p.y >= yMin) {
// Check if the point is within this line horizontally
if (p.x >= lineOrigin.x && p.x <= lineOrigin.x + width) {
// Convert CT coordinates to line-relative coordinates
CGPoint relativePoint = CGPointMake(p.x - lineOrigin.x, p.y - lineOrigin.y);
idx = CTLineGetStringIndexForPosition(line, relativePoint);
break;
}
}
}
free(linesOrigins);
return idx;
}

引用

Quartz 2D Programming Guide
Core Text Programming Guide
Text Programming Guide for iOS
Cocoa Text Architecture Guide
Core Text Tutorial for iOS: Making a Magazine App
初识 TextKit
Font wiki

iOS App自动监控Zombie对象方案

Posted on 2019-05-13 | Edited on 2019-05-17 | In ios
Symbols count in article: 3.6k | Reading time ≈ 7 mins.

iOS开发过程或者线上版本经常有Crash崩溃在objc_msgSend、objc_retain、objc_release等方法,这些都是典型等Zombie问题,在开发过程可以使用Instruments工具定位,但对于线上问题或者很难复的问题Instruments就很难定位了,如果能主动捕捉Zombie对象,并且Trace Zombie对象信息和释放栈,那就很容易分析问题了。本文介绍一种主动监控Zombie对象方案,方案已经上线验证一段时间了,并且已经在github上开源了。

为什么使用ARC还会有Zombie问题?

可能很多同学觉得使用ARC和weak属性后就不会有Zombie问题了,但App上线后还是会发现很多Zombie问题,主要是因为:

  • 还有很多地方使用assign
    由于历史原因,系统库还有地方使用assign,最典型的就是iOS8下UITableView delegate和dataSource,相信大部分iOS开发都遇到过这个问题导致的Crash,还有就是自己都代码也可能使用assign。
  • 线程安全问题
    虽然ARC下可以不用考虑对象释放问题,但如果不是线程安全的话,就算使用weak还是可能导致Zombie问题。

如何分析Zombie问题?

大多数Zombie问题都是Crash在objc_msgSend方法,一般都是消息转发的时候发生EXC_BAD_ACCESS错误,但从堆栈上无法看出是什么对象、什么方法出了问题,并且很多时候也不是Crash在我们自己但代码附近,这种情况就更难分析了。
我们知道调用objc_msgSend方法的时候前面几个参数是通过x0~x7寄存器传递的,其中x0是self指针,x1是SEL,如果能知道Crash在哪个SEL,结合代码有可能进一步分析出哪里Crash了。SEL是指向C string的指针,并且C string 保存在__TEXT段,所以结合dSYM和x1寄存器,正常情况下是可以解析出SEL的,具体解析方法可以参考「So you crashed in objc_msgSend()」。
如果SEL是很常见的方法,即使知道SEL还是很分析出问题,这个时候如果能知道Zombie对象类名,对象释放栈的话就能很容易分析出问题了,但仅通过Crash log文件是无法获取这些信息的,这也是大多数Zombie问题很难定位的原因。
其实除了Crash,Zombie也可能只是导致逻辑错误,这个时候就更难定位问题了,因为往往问题现场离对象释放的地方很远。

自动监控Zombie对象方案

如果能主动监测Zombie,在第一次使用Zombie对象的时候就发现问题,可以进行上报,也可以主动触发Crash,同时在释放对象的时候保存对象信息,可以知道到底是哪个对象出现了问题,这样可以极大的提高Zombie问题的发现率和解决率。
要在线上版本实时监控,监控组件必须对App性能影响很小,在设计对时候从下面点考虑:

  • Trace Zombie对象类名、selector、释放栈信息
  • 内存可控
  • 监控策略可控
  • 对cpu影响要很小

如何监控

要监控Zombie对象,就必须监控访问已经释放对对象,所以可以从下面几个点思考:

  1. 如何监控对象释放
    Objective-C上可以方便的使用Method swizzling hook dealloc方法监控对象的释放,Method swizzling只能监控Objective-C对象的释放;也可以在更底层hook free,使用Fishhook可以很方便的hook free方法,hook free可以监控所以对象的释放。
  2. 如何监控访问已经释放的对象
    可以使用Objective-C Runtime消息转发机制监控Objective-C对象访问,要监控C/C++对象访问复杂些,一种方法是对象释放后使用vm_protect设置虚拟内存为不可读写,不过vm_protect只能以内存页为单位进行设置。

####最终方案

因为iOS App上大部分自定义对象都是Objective-C对象,所以最终使用Method swizzling hook dealloc方法监控对象的释放,并且使用Runtime消息转发机制监控Objective-C对象访问,主要过程如下:

  1. hook dealloc方法,dealloc时只析构对象,不释放内存,更换isa指针指向ZombieHandler Class
  2. 延迟释放对象
  3. ZombieHandler Class拦截消息,从而监控使Zombie对象
    方案模块结构如下图:
    Zombie监控模块结构图

内存优化

一开始对象释放栈保存完整的栈,并且保存string类型,类似下面

“1 libdispatch.dylib 0x0000000021809823 0x21807000 + 10275”
“2 libdispatch.dylib 0x0000000021809823 0x21807000 + 10275”

后来改成只保存函数地址,并且arm64下每个地址只用40bit,iOS64位系统下每个地址其实只用了36位,使用40位方便操作,上报的时候也只上报函数地址,这样可以极大程度的减小组件占用的内存,优化后栈类型下面:

dealloc stack:{
tid:1027
stack:[0x0000000100047534,0x000000010004b2e4,0x00000001000498b0,0x000000018e9bdf9c,0x000000018e9bdb78,0x000000018e9c43f8,0x000000018e9c1894,0x000000018ea332fc,]
}

其实栈可以直接保存在延迟释放对象的内存上面,这样可以进一步优化内存使用。

开源

监控组件已经开源,并且提供符号化脚本,使用也很简单,只需要几行调用就可以:

1
2
3
4
5
6
7
8
9
10
//setup DDZombieMonitor
void (^zombieHandle)(NSString *className, void *obj, NSString *selectorName, NSString *deallocStack, NSString *zombieStack) = ^(NSString *className, void *obj, NSString *selectorName, NSString *deallocStack, NSString *zombieStack) {
NSString *zombeiInfo = [NSString stringWithFormat:@"ZombieInfo = \"detect zombie class:%@ obj:%p sel:%@\ndealloc stack:%@\nzombie stack:%@\"", className, obj, selectorName, deallocStack, zombieStack];
NSLog(@"%@", zombeiInfo);

NSString *binaryImages = [NSString stringWithFormat:@"BinaryImages = \"%@\"", [self binaryImages]];
NSLog(@"%@", binaryImages);
};
[DDZombieMonitor sharedInstance].handle = zombieHandle;
[[DDZombieMonitor sharedInstance] startMonitor];

组件支持下面特性

  • 主动监控Zombie问题,并且提供Zombie对象类名、selector、释放栈信息
  • 支持不同监控策略,包括App内自定义类、白名单、黑名单、所有对象
  • 支持设置最大占用内存
  • 组件在收到内存告警或超过最大内存时,通过FIFO算法释放部分对象

性能影响和稳定性

组件上线一两个版本了,目前还没发现Crash
cpu影响:打开Zombie检测前后相差0.2%左右,影响很小
内存影响:Zombie组件内存开关为10M的时候,实际内存增加11M左右,10M只计算了延迟释放对象和对象释放栈,组件本身占用内存没计算在内,符合预期

具体源码请移步「github DDZombieMonitor」

参考

So you crashed in objc_msgSend()
手动分析iOS Crash log庖丁解牛

UITableView常见Crash案例分析

Posted on 2019-05-13 | Edited on 2019-05-17 | In ios
Symbols count in article: 3.4k | Reading time ≈ 6 mins.

写在最前面

UITableView是iOS开发最常用的类,用起来很方便,但使用不当也很容易引起Crash,UICollectionView和UITableView很类似,本文重点讲UITableView。UITableView很大一部分Crash是由于dataSource同步问题导致的,但在不同的场景会有不同的Crash栈,本文会结合线上真实的案例讲解UITableView常见的Crash和分析过程。
UITableView常见Crash主要有下面几类:

  • dataSource更新后没同步刷新UITableView
  • UITableView dataSource is not set
  • Invalid update
  • failed to obtain a cell from its dataSource

dataSource更新后没同步刷新UITableView

这是最常见也是最低级的错误,我们APP在刚上线的时候有几粒这样的错误,后面大家注意后就很少见这类错误了。
这类错误主要是由于dataSource个数比tableview cell个数少,导致访问dataSource时数组越界,Crash栈上有NSRangeException异常,要避免这类错误主要注意下面几个点:

  • 更新完dataSource后在主线程立即调reloadData或其他api更新tableview
  • 子线程操作dataSource时需要线程安全,这其实是任何多线程都要注意的问题~
  • block dispatch到主线程执行并不等于一定没问题,由于dispatch到主线程block是一个一个执行的,并不能确保两个block连续执行,中间可能还会执行其他block,写代码时一定要注意这点。

UITableView dataSource is not set

这个是很少见但Crash率又很高的一个案例,分析这个Crash前前后后也化了很多时间。
Crash栈如下:

image.png

###第一次分析

这个Crash是在中间某个版本突然冒出来的,并且Crash量很多,Crash栈没有任何我们自己的代码,没法直接定位到问题源,所以首先要找出问题源在哪,在分析几个栈后发现Crash都在直播间附近,所以锁定问题出在直播间的聊天室。
之前没见过类似的Crash,Exception Codes是UITableView dataSource is not set,但肯定可以排除初始化tableview时没有设置dataSource,因为这样tableview并不会显示,所以怀疑是tableView:cellForRowAtIndexPath:返回了nil,分析代码逻辑发现tableView:cellForRowAtIndexPath:的确会返回nil,所以很开心的以为解决了这个问题。
然而新版上线后发现并没有解决~~

###再次分析

新版上线后还是有很多这个Crash,所以又硬着头皮分析了一下Crash log,仔细分析了Crash log后,发现Crash上都有[UITableView _updateAnimationDidStop:finished:context:],根据名字可以知道这个方法是在UITableView某些动画结束时调用的,我们的代码是通过insertRowsAtIndexPaths:withRowAnimation:更新tableview的,所以怀疑是这个方法触发的动画,通过断点分析后的确时这个方法触发的。
—————————-我是华丽的分割线—————————-
一开始的思路是能不能禁止这个方法调用,其实我们的代码在调用这个方法之前已经通过setAnimationsEnabled:来禁止动画了,在尝试了其他方案后都无法禁止这个方法调用。
—————————-我是华丽的分割线—————————-
没法禁止这个方法调用后,就分析什么为什么dataSource会是nil,分析了整个代码逻辑发现只有vc dealloc的时候才可能导致dataSource是nil,我们也没有在dealloc里显式设置dataSource,而是weak属性自动设的。正常的vc也都是这样写的,唯一需要在dealloc里设置tableview的delegate和dataSource也是兼容iOS9以下系统delegate是assign的问题。
—————————-我是华丽的分割线—————————-
现在大概可以确定是在调insertRowsAtIndexPaths:withRowAnimation:后操作还没结束vc被pop出去了,导致dataSource变为了nil。一开始的想法是能不能在dealloc的时候取消掉tableview所有动画,发现系统也没提供接口。

###最终解决方案

分析过程很冗长,但其实解决方法非常简单,简单到你都无法想象,只需要两行代码就可以解决~~
在dealloc里把dataSource设成nil,并且调用reloadData。

###思考

为什么我们平时用tableview的时候都没有在dealloc里把dataSource设成nil和reloadData也不会有问题?
原因是因为正常的场景在pop的时候tableview都没有动画,而我们的直播间里聊天室tableview不停的有新的cell插进来,所以在pop的时候有动画的概率很高。

这个crash主要原因是tableview更新的时候section和row不为0,所以调用cellForRowAtIndexPath创建cell,但创建的时候发现dataSource为nil,在numberOfRowsInSection方法里面返回非0,同时把dataSource设为nil可以复现这个crash

###关于reloadData的一些分析

通过断点分析reloadData方法发现下面几点:

  • 调用reloadData后会立即触发numberOfSectionsInTableView和numberOfRowsInSection调用,里面会更新tableview的section和row信息,但并一定会立即调cellForRowAtIndexPath
  • cellForRowAtIndexPath方法和numberOfRowsInSection并不是严格顺序执行的,这两个方法虽然都是在主线程runloop里执行,但两个方法中间可能执行很多其他操作
  • 就算section和row都为0,还是会调用[UITableView _updateVisibleCellsNow:isRecursive:]更新tableview,但不会调cellForRowAtIndexPath,这就是为什么调reloadData不会有这个crash了。

##Invalid update

这类crash一般时调用insertRowsAtIndexPaths:withRowAnimation:系列方法更新tableview时dataSource和tableview更新不匹配导致的。
crash栈:

image.png

crash栈的Exception Codes信息已经说的很清楚了,是因为tableview更新后cell个数跟numberOfRowsInSection返回的不一致,这类问题也出现了很多次,大多是我们使用不当导致的。
主要需要主要下面几点:

  • 在同一个 beginUpdates and endUpdates中,不管insertion and deletion的顺序如何,都会先执行deletion。
  • indexPaths里的index不能重复,上面这个crash就是因为insertRowsAtIndexPaths的时候逻辑错误导致indexPaths里的index重复了。

##failed to obtain a cell from its dataSource

这类问题比较少见,如果cellForRowAtIndexPath方法返回nil就会导致这种crash,crash栈如下:

image.png

##写在最后面

用系统组件的时候很多问题都是我们不了解组件的底层实现或者没按照api文档使用导致的,所以在使用一个组件的时候还是要多思考组件的底层机制。

iOS Crash log符号化庖丁解牛

Posted on 2019-05-13 | Edited on 2019-05-17 | In ios
Symbols count in article: 6.7k | Reading time ≈ 12 mins.

项目在做zombie内存监测的时候有把zombie调用栈和oc对象释放栈报上来,由于我们的crash组件是用的第三方组件,zombie栈没法和crash log一起符号化,要自己对栈进行符号化,研究了一下CrashLog的还原原理和方法。

###Crash Log符号还原方法

####使用xcode

这是最简单的方法,要使用xcode进行符号还原需要下面三个文件:

1
2
3
Crash Reports(.crash文件)
符号文件 (.dsymb文件)
应用程序文件 (appName.app文件)

把这3个文件放到同一个目录下,打开Xcode的Window菜单下的organizer,然后点击Devices tab,然后选中左边的Device Logs。然后把.crash文件拖到Device Logs或者选择下面的import导入.crash文件。

####使用symbolicatecrash

symbolicatecrash是xcode自带的工具
将“.app“, “.dSYM”和 “.crash”文件放到同一个目录下,终端设置如下环境:

1
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer

然后输入下面命令

1
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/PrivateFrameworks/DTDeviceKitBase.framework/Versions/A/Resources/symbolicatecrash appName.crash appName.app > appName.log

####使用atos命令行

前面两种方法足够简单,但都不适合脚本自动化,并且我们也没有原始.crash文件,
本文重点介绍这种方法和内部的原理。
atos命令可以对指定的地址进行符号化

NAME
atos – convert numeric addresses to symbols of binary images or processes
SYNOPSIS
atos [-o ] [-p | ] [-arch architecture][-l ] [-s ] [-printHeader] [-v] [-D] [-f ] [

…]
DESCRIPTION
The atos command converts numeric addresses to their symbolic equivalents. If full debug symbol infor-mation informationmation is available, for example in a .app.dSYM sitting beside a .app, then the output of atos will
include file name and source line number information.

###符号还原原理

首先来看下需要还原的栈长啥样

1
2
3
4
0   AppName                     0x00000001009e3110 _ZNSt3__111char_traitsIcE2eqEcc + 7884972
1 AppName 0x0000000100620f04 _ZNSt3__111char_traitsIcE2eqEcc + 3944096
2 CoreFoundation 0x0000000188e58f60 <redacted> + 132
3 CoreFoundation 0x0000000188d5280c _CF_forwarding_prep_0 + 92

看栈可以知道每个frame包括image名、代码地址、代码地址对应的符号信息,现在代码地址对应的符号是一串奇怪东东,我们要做的就是把这串奇怪的东东还原成可读的信息,包括函数名、原文件名、代码行。
要还原符号信息必须解决下面问题

  • 从哪里找函数名、原文件名、代码行这些信息
  • 怎么找这些信息

###从哪里找函数名、原文件名、代码行这些信息

####dSYM和DWARF

dSYM(debugging SYMbol)是从Mach-O文件中抽取调试信息而得到的文件目录,发布的时候为了安全和减小安全,一般会把调试信息存储在单独的文件,dSYM实际是一个文件目录,其目录结构如下:

1
2
3
4
5
6
|--AppName.app.dSYM
|--Contents
|--info.plist
|--Resources
|--DWARF
|--AppName

dSYM符号信息实际存储在DWARF文件里面,DWARF (DebuggingWith Arbitrary Record Formats)是起源贝尔实验室的一种调试信息文件格式,是ELF和Mach-O等文件格式中用来存储和处理调试信息的标准格式。
DWARF文件包含所有调试信息,并且以section的形式进行存储,DWARF使用DIE(Debugging Information Entry)来存储具体信息,DIE通过树结构组织,DIE可以有兄弟节点和子节点。DWARF文件包括下面section:

1
2
3
4
5
6
7
8
9
10
11
.debug_abbrev              Abbreviations used in the .debug_info section
.debug_aranges A mapping between memory address and compilation
.debug_frame Call Frame Information
.debug_info The core DWARF data containing DIEs
.debug_line Line Number Program
.debug_loc Macro descriptions
.debug_macinfo A lookup table for global objects and functions
.debug_pubnames A lookup table for global objects and functions
.debug_pubtypes A lookup table for global types
.debug_ranges Address ranges referenced by DIEs
.debug_str String table used by.debug_info

其中主要信息存储在debug_info和debug_line里,debug_info存储了函数信息、变量信息等,debug_line存储了对应源代码行数信息。可以用dwarfdump工具读取dwarf文件里的section。使用dwarfdump读取下面demo dSYM文件的section

1
2
3
4
@interface DSYMDemo : NSObject
@property (nonatomic, strong) NSString* var1;
- (NSString*)test;
@end

#####debug_info

使用dwarfdump读取DWARF文件debug_info信息

1
dwarfdump -e --debug-info DSYMDemo.app.dSYM/Contents/Resources/DWARF/DSYMDemo > debug-info.txt

1
2
3
4
5
6
7
8
9
10
11
0x00034d29:     function [119] *
low pc( 0x0000000100006adc )
high pc( 0x0000000100006b14 )
frame base( reg29 )
object pointer( {0x00034d49} )
name( "-[DSYMDemo setVar1:]" )
decl file( "/Users/haishengding/Desktop/test/DSYMDemo/DSYMDemo/DSYMDemo.h" )
decl line( 13 )
prototyped( 0x01 )
artificial( 0x01 )
APPLE optimized( 0x01 )

可以看到DIE里面包括了函数开始地址、结束地址、函数名、原文件名、开始地址在原文件的行数。对于给定的地址,找到函数开始地址和结束地址之间包含改地址的DIE,则可以还原函数名和原文件名。

#####debug_line

通过debug_info还原了函数名、原文件名,剩下原文行数则通过debug_line进行还原

1
dwarfdump -e --debug-line DSYMDemo.app.dSYM/Contents/Resources/DWARF/DSYMDemo > debug-line.txt

1
2
3
4
5
6
7
8
9
10
11
Address                Line  File
------------------ ------ ------------------------------
0x0000000100006ac0 13 ~/Desktop/test/DSYMDemo/DSYMDemo/DSYMDemo.m
0x0000000100006ac0 14
0x0000000100006acc 13 ~/Desktop/test/DSYMDemo/DSYMDemo/DSYMDemo.h
0x0000000100006acc 13
0x0000000100006adc 13
0x0000000100006aec 0
0x0000000100006b14 11 ~/Desktop/test/DSYMDemo/DSYMDemo/DSYMDemo.m
0x0000000100006b14 11
0x0000000100006b28 11

可以看到debug_line里面包含了每个代码地址对应的行数。

###怎么找这些信息

已经知道对于指定的地址,通过dSYM文件可以还原符号信息,但怎么拿到这个地址呢?开始的函数栈frame有一个地址,就是这个地址吗?当然没这么简单,我们知道image加载的时候都会相对基地址进行重定位,并且每次加载的基地址都不一样,函数栈frame的地址是重定位后的绝对地址,我们要的是重定位前的相对地址。

####Binary Images

1
2
3
4
Binary Images:
0x100050000 - 0x101c07fff +AppName arm64 <ab90a1c5646f35dca8f8cf1ce74c767c> /var/containers/Bundle/Application/175ED3FA-7329-49DE-B54A-88EEC120412C/AppName.app/AppName
0x187ee6000 - 0x187eeffff libsystem_pthread.dylib arm64 <d8480fc3a35d3475b0d12553c761d8cb> /usr/lib/system/libsystem_pthread.dylib
0x187e04000 - 0x187e28fff libsystem_kernel.dylib arm64

可以看到Crash Log的Binary Images块包含每个image加载起止地址、image名、arm架构、uuid、image路径。

1
2
frame
0 AppName 0x00000001009e3110 _ZNSt3__111char_traitsIcE2eqEcc + 7884972

1
2
Binary Image
0x100050000 - 0x101c07fff +AppName arm64 <ab90a1c5646f35dca8f8cf1ce74c767c> /var/containers/Bundle/Application/175ED3FA-7329-49DE-B54A-88EEC120412C/AppName.app/AppName

frame0的地址0x00000001009e3110-0x100050000 就是函数的相对地址,使用改地址通过dSYM文件就可以还原符号。

####UUID

符号还原的时候必须通过匹配的dSYM,dSYM和image是通过UUID进行关联的,两者的UUID必须一样才能正确还原,image的UUID在Binary Images可以拿到,dSYM 的UUID可以通过dwarfdump读取

1
2
dwarfdump -u -arch arm64 AppName.app.dSYM/Contents/Resources/DWARF/AppName
UUID: AB90A1C5-646F-35DC-A8F8-CF1CE74C767C (arm64) AppName.app.dSYM/Contents/Resources/DWARF/AppName

可以看到读取的跟image里的是一样的。

###使用atos命令行

上面讲了符号还原的原理,实际上atos工具帮我们做了这些事情,只要通过简单的命令就看还原了

1
2
atos -o AppName.app.dSYM/Contents/Resources/DWARF/AppName -arch arm64 -l 0x100050000 0x00000001009e3110
currentCallStack (in AppName) (xxx.m:17)

其中0x100050000是image加载地址, 0x00000001009e3110是需要符号还原的绝对地址,atos自己会转成相对地址

###系统符号

dSYM文件只有我们自己代码的符号,系统函数则必须通过系统符号文件进行还原,系统符号一般存储在~/Library/Developer/Xcode/iOS DeviceSupport目录

1
2
0x187ee6000 - 0x187eeffff  libsystem_pthread.dylib arm64 <d8480fc3a35d3475b0d12553c761d8cb> /usr/lib/system/libsystem_pthread.dylib
OS Version: iPhone OS 10.2 (14C92)

通过Crash Log文件Bianry Images和OS Version信息可找到对应的符号文件。

###脚本化

知道怎么还原具体frame栈桢的符号了,通个脚本解析每个栈桢就可以自动化还原整个栈了,像buggly平台应该也是用类似的方案。

以上内容为本人工作学习中所得,如有错误之处,还请指出!

###参考文件

Symbolicating Your iOS Crash Reports
Understanding and Analyzing Application Crash Reports
Introduction to the DWARF Debugging Format

iOS WebView的一些总结

Posted on 2019-05-13 | Edited on 2019-05-17 | In ios
Symbols count in article: 1.2k | Reading time ≈ 2 mins.

app开发中一些重运营业务大多都通过web来实现快速迭代,iOS可以使用UIWebView、WKWebView实现Native和Web的交互。之前在项目中负责过WebView模块,所以在这里做一些WebView的总结,主要从包括以下一些点:

  1. 为什么要封装WebView
  2. JSBridge
  3. 离线包
  4. WKWebView

为什么要封装WebView

我们项目中把WebView封装成了一个ViewController,可能很多人会觉得iOS里要现实web不就直接用UIWebView和WKWebView吗,为什么还要进行封装,我觉的主要是从下面点来考虑:

  1. 统一ui样式。包括导航条,进度条,错误页面等,避免重复造轮子。
  2. 封装基础能力。抽象业务无关的基础能力,比如导航条设置(更改导航条颜色,设置ButtonItem)、cookie设置、UA设置、JSBridge、数据上报等
  3. hook request。进行安全校验,鉴权处理

JSBridge

iOS SDK 直接支持native调用js,UIWebView调用stringByEvaluatingJavaScriptFromString,WKWebView调用evaluateJavaScript: completionHandler

iOS SDK 没有天生支持 js 和 native 相互调用,都是自己实现一套调用机制,目前主要有下面几种方法:

  1. 通过iframe
    通过创建iframe,然后在webview delegate hook请求
  2. JavaScriptCore
    iOS 7开始引进了JavaScriptCore可以方便高效实现JS和oc之间的调用
  3. WKScriptMessageHandler
    WKWebView可以使用WKScriptMessageHandler来实现JS调用oc方法

离线包

主要是通过NSURLProtocol hook request来实现离线包,严格来说跟WebView没半毛钱关系。

WKWebView
iOS8引入了WKWebView,WKWebView使用独立的进程渲染web,解决了UIWebView内存泄漏和crash率高的问题,但是理想虽然很丰满,现实却很骨感,实际使用WKWebView还是会有很多问题。之前在项目中做过切换WKWebView,刚好可以总结下遇到的问题和解决方法,主要有下面问题:

  1. 没法直接设置Cookie
    WKWebView不使用NSHTTPCookieStorage,所以没法直接设置Cookie,app中web请求一般都会通过cookie带登录态到后台,如果没法设置cookie的话是没法正常工作的。尝试过几种方案后我们最终是通过request header把cookie带到server,同时通过WKUserScript把cookie种到本地。
  2. 不经过URL Load System
    这样就不能使用NSURLProtocol来实现离线包和定制request了

Alex Ting

7 posts
2 categories
4 tags
GitHub E-Mail
© 2019 Alex Ting | Symbols count total: 52k | Reading time total ≈ 1:34
Powered by Hexo v3.8.0
|
Theme – NexT.Pisces v7.1.1