0%

Data transfer

We can’t directly pass Dictionary to Native, because yogo will parse these large amounts of data, which will cause performance problems. We’d better serialize them into JSON strings and pass large amounts of data between JS and Native. We calculate all index values in the background thread and then update the UI on the main thread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// RNKLineView
@objc func reloadOptionList(_ reactTag: NSNumber, optionList: String) {
bridge.uiManager.addUIBlock { (uimanager, viewRegistry) in
guard let viewRegistry = viewRegistry, let containerView = viewRegistry[reactTag] as? HTKLineContainerView else {
return
}
type(of: self). queue. async {
do {
guard let optionList = try JSONSerialization.jsonObject(with: optionList.data(using: .utf8) ?? Data(), options: .allowFragments) as? [String: Any] else {
return
}
containerView.configManager.reloadOptionList(optionList)
DispatchQueue. main. async {
containerView. reloadConfigManager(containerView.configManager)
}
} catch(_) {

}
}
}
}

Chart wrapper

There are many kinds of charts and indicators that need to be drawn, so we encapsulate the same parts of them into protocols. Similarly, in swift, we can add default implementations to protocols, or add public methods

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//HTKLineDrawProtocolx
protocol HTKLineDrawProtocol: class {

func minMaxRange(_ visibleModelArray: [HTKLineModel], _ configManager: HTKLineConfigManager) -> Range<CGFloat>

func drawCandle(_ model: HTKLineModel, _ index: Int, _ maxValue: CGFloat, _ minValue: CGFloat, _ baseY: CGFloat, _ height: CGFloat, _ context: CGContext, _ configManager: HTKLineConfigManager)

func drawLine(_ model: HTKLineModel, _ lastModel: HTKLineModel, _ maxValue: CGFloat, _ minValue: CGFloat, _ baseY: CGFloat, _ height: CGFloat, _ index: Int, _ lastIndex: Int, _ context: CGContext, _ configManager : HTKLineConfigManager)

func drawText(_ model: HTKLineModel, _ baseX: CGFloat, _ baseY: CGFloat, _ context: CGContext, _ configManager: HTKLineConfigManager)

func drawValue(_ maxValue: CGFloat, _ minValue: CGFloat, _ baseX: CGFloat, _ baseY: CGFloat, _ height: CGFloat, _ context: CGContext, _ configManager: HTKLineConfigManager)

}

Chart drawing

Drawing processing

We need a common point conversion method. We first draw the candles of the main chart and the sub-chart, and then draw the text, ruler, maximum and minimum, time scale, current price horizontal line, long-pressed detail panel, etc.

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
//HTKLineView
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), configManager.modelArray.count > 0 else {
return
}

calculateBaseHeight()
contextTranslate(context, CGFloat(visibleRange.lowerBound) * configManager.itemWidth, { context in
drawCandle(context)
})

contextTranslate(context, contentOffset.x, { context in

drawText(context)
drawValue(context)



drawHighLow(context)
drawTime(context)
drawClosePrice(context)
drawSelectedLine(context)
drawSelectedBoard(context)
drawSelectedTime(context)

drawContext. draw(contentOffset. x)
})


}

Scroll Gesture Handling

When the ScrollView scrolls horizontally, in order to save performance, we only draw in the visible range of the screen, and then calculate the accurate offset for drawing

1
2
3
4
5
6
7
8
9
10
//HTKLineView
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let contentOffsetX = scrollView. contentOffset. x
var visibleStartIndex = Int(floor(contentOffsetX / configManager. itemWidth))
var visibleEndIndex = Int(ceil((contentOffsetX + scrollView. bounds. size. width) / configManager. itemWidth))
visibleStartIndex = min(max(0, visibleStartIndex), configManager.modelArray.count - 1)
visibleEndIndex = min(max(0, visibleEndIndex), configManager.modelArray.count - 1)
visibleRange = visibleStartIndex...visibleEndIndex
self. setNeedsDisplay()
}

Zoom gesture handling

When the ScrollView pinch zoom gesture, we need to recalculate the offset for drawing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//HTKLineView
@objc
func pinchSelector(_ gesture: UIPinchGestureRecognizer) {
switch gesture. state {
case.changed:
scale += (gesture. scale - 1) / 10
default:
break
}
scale = max(0.3, min(scale, 3))

let width = bounds. size. width
let halfWidth = width / 2
let offsetScale = (contentOffset.x + halfWidth) / (contentSize.width - configManager.paddingRight)

reloadContentSize()
let contentOffsetX = max(0, min((contentSize. width - configManager. paddingRight) * offsetScale - halfWidth, contentSize. width - width))
reloadContentOffset(contentOffsetX)
scrollViewDidScroll(self)
}

Long press gesture panel processing

When ScrollView receives a long press or click gesture, we need to show or hide the details panel

1
2
3
4
5
6
7
8
9
10
//HTKLineView
@objc
func longPressSelector(_ gesture: UILongPressGestureRecognizer) {
let index = Int(floor(gesture. location(in: self). x / configManager. itemWidth))
selectedIndex = index
if (selectedIndex >= configManager. modelArray. count) {
selectedIndex = configManager.modelArray.count - 1
}
self. setNeedsDisplay()
}

Chart magnifying glass

We integrate all the charts into the container HTKLineContainerView, where we handle the display of the magnifying glass according to gestures

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//HTKLineContainerView
func touchesGesture(_ touched: Set<UITouch>, _ state: UIGestureRecognizerState) {
guard var location = touched.first?.location(in: self) else {
shotView. shotPoint = nil
return
}
var previousLocation = touched.first?.previousLocation(in: self) ?? location
location = convertLocation(location)
previousLocation = convertLocation(previousLocation)

let translation = CGPoint.init(x: location.x - previousLocation.x, y: location.y - previousLocation.y)

klineView.drawContext.touchesGesture(location, translation, state)
shotView.shotPoint = state != .ended ? touched.first?.location(in: self) : nil
}

Finger marker drawing

Our finger mark drawings are all drawn through HTDrawContext, we need to pay attention to including parallelograms, we need to convert all coordinates to the current price coordinates, because our zoom gesture needs to keep these finger mark drawings in the correct position

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
//HTKLineContainerView
func reloadConfigManager(_ configManager: HTKLineConfigManager) {

configManager.onDrawItemDidTouch = { [weak self] (drawItem, drawItemIndex) in
self?.configManager.shouldReloadDrawItemIndex = drawItemIndex
guard let drawItem = drawItem, let colorList = drawItem.drawColor.cgColor.components else {
self?.onDrawItemDidTouch?([
"shouldReloadDrawItemIndex": drawItemIndex,
])
return
}
self?.onDrawItemDidTouch?([
"shouldReloadDrawItemIndex": drawItemIndex,
"drawColor": colorList,
"drawLineHeight": drawItem. drawLineHeight,
"drawDashWidth": drawItem. drawDashWidth,
"drawDashSpace": drawItem. drawDashSpace,
"drawIsLock": drawItem. drawIsLock
])
}
configManager.onDrawItemComplete = { [weak self] (drawItem, drawItemIndex) in
self?.onDrawItemComplete?([AnyHashable: Any].init())
}
configManager.onDrawPointComplete = { [weak self] (drawItem, drawItemIndex) in
guard let drawItem = drawItem else {
return
}
self?.onDrawPointComplete?([
"pointCount": drawItem.pointList.count
])
}

let reloadIndex = configManager.shouldReloadDrawItemIndex
if reloadIndex >= 0, reloadIndex < klineView.drawContext.drawItemList.count {
let drawItem = klineView.drawContext.drawItemList[reloadIndex]
drawItem.drawColor = configManager.drawColor
drawItem.drawLineHeight = configManager.drawLineHeight
drawItem.drawDashWidth = configManager.drawDashWidth
drawItem.drawDashSpace = configManager.drawDashSpace
drawItem.drawIsLock = configManager.drawIsLock
if (configManager. drawShouldTrash) {
configManager.shouldReloadDrawItemIndex = HTDrawState.showPencil.rawValue
klineView.drawContext.drawItemList.remove(at: reloadIndex)
configManager.drawShouldTrash = false
}
klineView.drawContext.setNeedsDisplay()
}

if configManager. shouldFixDraw {
configManager.shouldFixDraw = false
klineView.drawContext.fixDrawItemList()
}
if (configManager. shouldClearDraw) {
configManager.drawType = .none
configManager.shouldClearDraw = false
klineView.drawContext.clearDrawItemList()
}
}

Affiliated

The full code is here https://github.com/hellohublot/react-native-kline-view
I’m hublot, sharing some of my thoughts, I’m too busy with work, it’s inevitable that there will be negligence, please forgive me

react-native-swipe-tabs why can’t meet my needs

We can know that the best animation performance of ReactNative is LayoutAnimation UIView animation, The best gesture performance is to use NativeEvent. These two solutions point to one direction, that is, to solve the performance problem of ReactNative. We can’t let the busy JS thread participate in these calls, so in terms of performance, I need to change from color to gradient The sliding and size of the text scaling and the scroll bar are all driven by the onScroll nativeEvent event, which is completely separated from the JS call to achieve the best performance

How to realize Label’s color gradient and text size scaling according to onScroll

We first bind the transform of the Label to the onScroll event. When Native needs to change the transform according to the onScroll calculation, we take the opportunity to change the gradient color of the Native

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//HTPageHeaderView
_itemTitleProps = (item, index) => {
let fontScale = 1 + (this.itemTitleSelectedStyle.fontSize - this.itemTitleNormalStyle.fontSize) / this.itemTitleNormalStyle.fontSize
if (fontScale == 1) {
fontScale = 1.0001
}
let scale = this.scrollPageIndexValue.interpolate({
inputRange: [index - 2, index - 1, index, index + 1, index + 2],
outputRange: [1, 1, fontScale, 1, 1]
})
let titleStyle = {
...
style: {
...this?.props?.itemTitleStyle,
transform: [{ scale }]
},
}
return titleStyle
}

We not only need to change the gradient color of the text, because the size of the text has also been changed, so we need to reset the size of the component through setIntrinsicContentSize for other components to re-typesetting

1
2
3
4
5
6
7
8
9
10
11
12
13
//HTSelectedLabel
- (void) reloadContentSize {
CGFloat progress = (self.layer.transform.m11 - 1) / (self.selectedScale - 1);
self.textColor = [[self class] appendColor:self.normalColor selectedColor:self.selectedColor progress:progress];
CGSize contentSize = [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
CGSize size = self. bounds. size;
if (size.width * size.height != 0 && !CGSizeEqualToSize(size, CGSizeZero)) {
contentSize = [self sizeThatFits:size];
}
if (size.width != contentSize.width || size.height != contentSize.height) {
[self.bridge.uiManager setIntrinsicContentSize:contentSize forView:self];
}
}

How to change the position and length of Cursor according to onScroll

If there is a fixed cursor length, then we can calculate the x offset that the cursor should have for each index
If there is no fixed cursor length, then we take the length of Label as the standard

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
//HTPageHeaderCursor
_reloadPageIndexValue = (isWidth) => {
let fixCursorWidth = this._findFixCursorWidth()
let rangeList = (isIndex) => {
let itemList = [isIndex? -1 : 0]
itemList = itemList.concat(this.itemContainerLayoutList.map((item, index) => {
if (isIndex) {
return index
} else {
if (item) {
if (fixCursorWidth) {
return isWidth ? fixCursorWidth : (item. x + (item. width - fixCursorWidth) / 2.0)
} else {
return isWidth ? item. width : (item. x + (item. width) / 2.0)
}
} else {
return 0
}
}
}))
itemList = itemList. concat([isIndex ? itemList. length - 1 : 0])
return itemList
}
return this.props.scrollPageIndexValue.interpolate({
inputRange: rangeList(true),
outputRange: rangeList(false),
})
}

Affiliated

The full code is here https://github.com/hellohublot/react-native-selected-page
I’m hublot, sharing some of my thoughts, I’m too busy with work, it’s inevitable that there will be negligence, please forgive me

Where is the performance bottleneck of react-navigation

We can know that the best animation performance of ReactNative is LayoutAnimation UIView animation, The best gesture performance is to use NativeEvent. These two solutions point to one direction, that is, to solve the performance problem of ReactNative. We cannot let busy JS threads participate in these calls. However, react-navigation from onPress to navigation.push There are JS calls, so our task today is to customize a navigation architecture to greatly improve the performance of our ReactNative application

Encapsulate NavigationController

Starting from the animation, we need to use the UIView process animation that comes with Native. Then the iOS platform already has a UINavigationController library. We only need to create a UINavigationController on the Android platform.

  • We need to handle the hiding of the TabBar
  • We need to hide the Controller that has been covered by the top page to save memory
  • And we need to handle viewDidAppear and viewDidDisappear lifecycle
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
// NavigationController
public void popToViewController(final HTRouteController controller, Boolean animated) {
if (childControllerList. size() <= 1) {
return;
}
if (this. lock) {
return;
}
this. lock = true;
final int index = childControllerList. indexOf(controller);
final HTRouteController animatedController = childControllerList. get(childControllerList. size() - 1);
if (index == 0) {
HTRouteTabBarController tabBarController = HTRouteGlobal.nextController(getView(), HTRouteTabBarController.class);
if (tabBarController != null) {
tabBarController. reloadShowTabBar(true);
}
}
controller.getView().setVisibility(View.VISIBLE);
translateAnimation(animatedController, animatedController.getView(), false, animated, new Callback() {
@Override
public void invoke(Object... args) {
controller. viewDidAppear();
animatedController. viewDidDisappear();
int size = childControllerList. size();
List<HTRouteController> willRemoveControllerList = new ArrayList<>();
for (int i = index + 1; i < size; i ++) {
HTRouteController removeController = childControllerList. get(i);
willRemoveControllerList. add(removeController);
}
for (HTRouteController removeController: willRemoveControllerList) {
removeChildController(removeController);
}
lock = false;
}
});
}

Package TabBar

From gesture analysis, we need to avoid JS.onPress, so we use the package of UITabBarController and UITabBar on the iOS platform. We only need to create a UITabBar and UITabBarController on the Android platform.

  • Due to the diversity of TabBar, we just encapsulate a common example, and other projects can inherit and rewrite
  • For convenience, we use the code layout
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // TabBar
    public void reloadData() {
    this. removeAllViews();
    this.initSeparatorLine(this);
    LinearLayout linearLayout = new LinearLayout(HTRouteGlobal. activity);
    this.addView(linearLayout, HTRouteGlobal.matchParent);
    linearLayout.setOrientation(LinearLayout.HORIZONTAL);

    TypedValue typedValue = new TypedValue();
    int imageId = android.R.attr.selectableItemBackground;
    HTRouteGlobal.activity.getTheme().resolveAttribute(imageId, typedValue, true);
    int[] attribute = new int[]{imageId};
    TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(typedValue.resourceId, attribute);

    for (int index = 0; index < delegate. itemCount(); index ++) {
    ...
    linearLayout. addView(relativeLayout);
    reloadItemIndex(index);

    }
    }

Encapsulate TabBarController

  • For the Controller that has been covered by the top page, we need to move it out of the Fragment to save memory
  • And we need to handle viewDidAppear and viewDidDisappear lifecycle
    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
    @Override
    public void didItemSelected(int index) {
    HTRouteFragment fragment = modelList.get(index).fragment;

    if (fragmentContainer. getChildCount() > 0) {
    HTRouteFragment lastFragment = (HTRouteFragment) fragmentContainer.getChildAt(0).getTag();
    lastFragment. viewDidDisappear();
    }


    View view = fragment. getView();
    fragmentContainer. removeAllViews();

    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);

    int bottom = 0;
    if (!TextUtils.isEmpty(HTRouteGlobal.getProp("ro.build.version.emui"))) {
    bottom = 1;
    }
    layoutParams.setMargins(0, 0, 0, bottom);
    fragmentContainer.addView(view, layoutParams);

    fragment. viewDidAppear();

    }

Encapsulate RouteView

The most important point is that we can bind the pages and parameters that need to be redirected to RouteView. When the original click event is received, we can directly jump to the native page without going through JS.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//HTRouteViewManager
@ReactProp(name = "routeData")
public void setRouteData(HTRouteView routeView, ReadableMap routeDataMap) {
final Map<String, Object> routeData = routeDataMap == null ? null : routeDataMap.toHashMap();
if (routeData != null && routeData. size() > 0) {
routeView.setClickable(true);
final WeakReference<HTRouteView> weakRouteView = new WeakReference<>(routeView);
routeView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
touchRouteData(weakRouteView. get(), routeData);
}
});
} else {
routeView.setClickable(false);
routeView.setOnClickListener(null);
}
}

All page jump processing

Including push replace presentModal all common processing

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
//HTRouteViewManager
public static void handlerRouteDataWithController(HTRouteController controller, @Nullable Map<String, Object> routeData) {
...
if (action. equals("push") || action. equals("navigate")) {
...
navigationController.pushViewController(routeController, animated, null);
} else if (action. equals("replace")) {
...
navigationController. replaceViewController(routeController, animated);
} else if (action. equals("pop") || action. equals("back") || action. equals("goBack")) {
navigationController. popViewController(animated);
} else if (action. equals("popToTop") || action. equals("popToRoot")) {
navigationController.popToRootViewControllerAnimated(animated);
} else if (action. equals("present")) {
...
presentView.addView(presentBackgroundView, HTRouteGlobal.matchParent);
translatePresentAnimation(presentBackgroundView, presentNavigationController.getView(), presentEdgeTop, true, presentAnimatedDuration, new Callback() {
@Override
public void invoke(Object... args) {
presentNavigationController. viewDidAppear();
}
});
} else if (action. equals("dismiss")) {
final RelativeLayout presentView = rootPresentViewController();
for (int i = 0; i < presentView. getChildCount(); i ++) {
final ViewGroup presentBackgroundView = (ViewGroup) presentView. getChildAt(i);
View navigationControllerView = presentBackgroundView. getChildAt(0);
final HTRouteNavigationController presentNavigationController = HTRouteGlobal.nextController(navigationControllerView, HTRouteNavigationController.class);
HTRouteController routeController = presentNavigationController. childControllerList. get(0);
if (routeController. componentName. equals(componentName)) {
translatePresentAnimation(presentBackgroundView, presentNavigationController.getView(), presentEdgeTop, false, presentAnimatedDuration, new Callback() {
@Override
public void invoke(Object... args) {
presentView. removeView(presentBackgroundView);
presentNavigationController. viewDidDisappear();
presentNavigationController. dealloc();
}
});
}
}
}
}

Same interface as react-navigation

We need to leave the same interface as react-navigation on the JS side to facilitate switching at any time

JS parameter passing

If function parameters need to be passed between pages, we need to store these variables in global variables, and then retrieve them through the key on the second page, because functions cannot be serialized

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
// index.js
const encodeRouteData = (routeData, navigation) => {
let reloadRouteData = { ...routeData }
let componentName = reloadRouteData?.componentName
let componentPropList = reloadRouteData?. componentPropList ?? {}
let componentRouteOptionList = reloadRouteData?. componentRouteOptionList ?? {}
componentRouteOptionList = {
...HTRouteManager. defaultRouteOption({ ...routeData, navigation }),
...componentRouteOptionList,
}

if (typeof(componentName) == 'string' && !globalValue.registerList[componentName]) {
return null
}


let id = componentRouteOptionList['id']
if (id == null) {
globalValue.count += 1
id = `${globalValue.count}`
}
componentRouteOptionList['id'] = id

reloadRouteData['componentRouteOptionList'] = componentRouteOptionList

sureComponentItem(id, { componentPropList })

return reloadRouteData
}

const decodeRouteData = (props) => {
let reloadProps = { ... props }
let componentRouteOptionList = reloadProps?. componentRouteOptionList ?? {}
let id = componentRouteOptionList?.id
sureComponentItem(id)

let componentValueList = globalValue. componentList[id] ?? {}
let componentPropList = componentValueList['componentPropList'] ?? {}
reloadProps.componentPropList = componentPropList
return reloadProps
}

JS Lifecycle

We need to receive the viewDidAppear and viewDidDisappear life cycle events sent by the native side, and then call the method of the corresponding component through the id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// index.js
const routeListener = HTRouteEventManagerEmitter. addListener('onHTRouteEventChange', (value) => {
let id = value['id']
let actionName = value['actionName']
let componentRef = globalValue.componentList[id]?.ref
if (componentRef == null) {
return
}
DeviceEventEmitter. emit('onHTRouteEvent', { title: 'controller', value: value })

if (value?. actionName == 'dealloc') {
globalValue.componentList[id].ref = null
return
}


let componentFunction = componentRef[actionName]
if (componentFunction == null) {
return
}
componentFunction. call(componentRef, value)
})

Affiliated

The full code is here https://github.com/hellohublot/react-native-route
I’m hublot, sharing some of my thoughts, I’m too busy with work, it’s inevitable that there will be negligence, please forgive me

Why is it stuck


Although the Main thread of most ReactNative applications does keep 60FPS, why sometimes we still feel stuck,
In fact, this is because of the asynchronous communication between the JS thread and the Main thread, resulting in the user’s gestures not being feedback in time, so our article mainly talks about the performance optimization of ReactNative applications


Animation

JS frame animation

1
2
3
4
5
6
7
8
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/Libraries/Animated/animations/TimingAnimation.js#L126
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/Libraries/Core/Timers/JSTimers.js#L257
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/React/CoreModules/RCTTiming.mm#L334
// TimingAnimation
// Through the above call chain, you can see that the JS frame animation is triggered based on the timer loop
this._animationFrame = requestAnimationFrame(
this.onUpdate.bind(this),
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/React/CoreModules/RCTTiming.mm#L296
// RCTTiming
// Start an NSTimer on the JS thread
- (void)scheduleSleepTimer:(NSDate *)sleepTarget
{
@synchronized(self) {
if (!_sleepTimer || !_sleepTimer.valid) {
_sleepTimer = [[NSTimer alloc] initWithFireDate:sleepTarget
interval: 0
target:[_RCTTimingProxy proxyWithTarget:self]
selector: @selector(timerDidFire)
userInfo: nil
repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:_sleepTimer forMode:NSDefaultRunLoopMode];
} else {
_sleepTimer.fireDate = [_sleepTimer.fireDate earlierDate:sleepTarget];
}
}
}

requestAnimationFrame is a timer function exposed by Native to JS. Every time the timer triggers a frame animation, the JS thread will recalculate the layout, and then switch to the Main thread to update these layouts. Ordinary JS animations are generally very stuck, because the frame timer is in the The thread may be very busy, the communication between threads may take time to wait, and the frame timer keeps making the main thread refresh the interface while the main thread may also be busy, so it is very common to encounter performance problems when using JS animation. What are the common solutions?

Native frame animation useNativeDriver

1
2
3
4
5
6
7
8
9
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/Libraries/NativeAnimation/RCTNativeAnimatedNodesManager.m#L429
// RCTNativeAnimatedNodesManager
// Start a CADisplayLink on the main thread
- (void)startAnimationLoopIfNeeded {
if (!_displayLink && _activeAnimations.count > 0) {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(stepAnimations:)];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/Libraries/NativeAnimation/Drivers/RCTFrameAnimation.m#L85
// RCTFrameAnimation
// The selector bound to CADisplayLink will calculate the value change, and then directly call setNeedsDisplay on the main thread
- (void)stepAnimationWithTime:(NSTimeInterval)currentTime
{
...
[self updateOutputWithFrameOutput:frameOutput];
}

- (void)updateOutputWithFrameOutput:(CGFloat)frameOutput
{
...
[_valueNode setNeedsUpdate];
}

useNativeDriver, what is the actual function of this property? Looking the source code of ReactNative or react-native-reanimated, we can see that a variable value is bound in the native side, and a CADisplayLink is created. This frame timer is to change the bound variable value frame by frame, and then refresh the corresponding interface after the value is changed in the main thread, which will not involve JS thread communication, so most of the cases can help solve our performance problems, but there are sometimes we will still encounter animation performance problems that cannot be solved by useNativeDriver, Because the frame loss of CADisplayLink frame animation, how do we deal with this situation?

Native layout animation LayoutAnimation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/React/Modules/RCTLayoutAnimation.m#L107-L127
// RCTLayoutAnimation
// LayoutAnimation will perform UIView animation through UIManager
- (void)performAnimations:(void (^)(void))animations withCompletionBlock:(void (^)(BOOL completed))completionBlock
{
if (_animationType == RCTAnimationTypeSpring) {
[UIView animateWithDuration:_duration
delay:_delay
usingSpringWithDamping:_springDamping
initialSpringVelocity:_initialVelocity
options:UIViewAnimationOptionBeginFromCurrentState
animations: animations
completion:completionBlock];
} else {
UIViewAnimationOptions options =
UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionsFromRCTAnimationType(_animationType);

[UIView animateWithDuration:_duration
delay:_delay
options:options
animations: animations
completion:completionBlock];
}
}

LayoutAnimation can solve this problem, and why, we can find out by looking at the source code, because LayoutAnimation does not start the native frame animation, but the native UIView animation that comes with iOS, because the animation of iOS is a separate process for animation. This animation is a process animation and has nothing to do with threads, so even if the Main thread is too busy, the animation process will directly complete the animation. Similarly, the animation framework of android may also have its own optimization, such as hardware acceleration, etc.

Gesture Events

JS Gesture Events

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/React/Base/RCTTouchHandler.m#L186
// RCTTouchHandler
// Every touch event on RootContentView will be sent to JS
- (void)_updateAndDispatchTouches:(NSSet<UITouch *> *)touches eventName:(NSString *)eventName {
...
RCTTouchEvent *event = [[RCTTouchEvent alloc] initWithEventName:eventName
reactTag: self.view.reactTag
reactTouches: reactTouches
changedIndexes: changedIndexes
coalescingKey:_coalescingKey];

if (!canBeCoalesced) {
_coalescingKey++;
}

[_eventDispatcher sendEvent:event];
}

Ordinary JS gesture processing is generally very slow, because the main thread receives the gesture, sends the gesture to the JS thread, and then executes the JS method. The Main thread receives the interface properties that should be updated, which involves two cross-threads Transferring data, among them, it is absolutely possible to wait for communication. How do we usually solve it?

Native Gesture Events

Similarly, the useNativeDriver mentioned above, we also have an event called nativeEvent, usually, them are used together, the native gesture will send a global notification, the animation framework will listen to this event, and then directly update the value of the animation binding As well as refreshing the interface directly on the main thread, we also don’t need asynchronous JS thread communication, so react-native-gesture-handler really solves a lot of performance problems

1
2
3
4
5
6
// https://github.com/facebook/react-native/blob/26b2bb5343f92672ed4e8f42c6f839e903124b06/Libraries/NativeAnimation/RCTNativeAnimatedModule.mm#L76
// RCTNativeAnimatedModule
// The animation framework will listen to all Event events, and then judge whether to update the bound animation value value according to the key
- (void)initialize {
[[self.moduleRegistry moduleForName:"EventDispatcher"] addDispatchObserver:self];
}

Affiliated

I’m hublot, sharing some of my thoughts, I’m too busy with work, it’s inevitable that there will be negligence, please forgive me