2016-10-01 14:55:19

[性能优化实践] React Native

使用setNativeProps

setNativeProps和setState效果类似, 但是更加深入底层. 它不但可以设置非State的属性, 例如Style等, 而且不会像setState那样触发整个组件的重绘.

setNativeProps常用在动画中. 以Material Design为例, 浮动按钮要求在未点击时高度为6, 点击时高度为12, 这可以用TouchableHighlight的onShowUnderlayonHideUnderlay来实现点击和未点击时的style切换. setState来做的话, 类似代码如下

export default class Home extends Component {
  constructor(props) {
    super(props);
    this.state = { pressStatus: false };
  }
  render(){
    return (
      <View style={styles.container}>
        <TouchableHighlight
          style={ this.state.pressStatus ? styles.buttonPress : styles.button }
          onHideUnderlay={()=> this.setState({ pressStatus: false });}
          onShowUnderlay={()=> this.setState({ pressStatus: true });}
        >
        </TouchableHighlight>
      </View>
    )
  }

但是setState会触发组件重绘, 一般情况下会延迟超过300毫秒, 并且随着组件复杂度和子组件数量的增加而增加, 还会受到Animated动画组件的影响.

以符合Material Design的一个浮动按钮为例, 除了高度的变化外, 移动或形变等动画也是经常遇到的, 使用setNativeProps的一个范例片段如下, 在此情况, 点击和松开的按钮阴影变化几乎没有任何延迟

setNativeProps和Touchable*的配合在官方文档上也有, 但是, 有另外一个更加极端的情况没有在官方文档上提到, 那就是 ListView + RefreshControl配合时的Refresh Indicator的刷新延迟问题

优化RefreshControl的Refresh Indicator严重的刷新延迟

如果使用setState或者Redux的继承Props后修改Props来更新RefreshControl的refreshing值, 那么ListView的重绘次数从1次直接恐怖地升到了3次(false => true => false). 因此更新RefreshControl的refreshing值的时间延迟约等于ListView重绘两次的时间, 即使RN内置的Diff跳过了ListView这额外的两次重绘, 但深度对比造成的延迟仍然十分严重, 以我正在写的APP为例, 上拉手势完成到RefreshControl出现之间居然有1000毫秒的延迟 ,这简直是不可原谅的, 不优化这种延迟的话, react native就不用写了, 比Hybrid App还不如.

更加好的办法当然是上面提到的setNativeProps, 注意, 一些平台独占的控件中, setNativeProps方法实际上是挂载在控件的_nativeRef属性上的 via.你自己得到的Ref中的setNativeProps方法是无效且会报错的, 因为缺少this.viewConfig.validAttributes等属性.RefreshControl就是这样的控件.

因此, 使用setNativeProps来优化RefreshControl的代码可以是如下的:

如上代码使用了组件生命周期之一的componentDidUpdate, 在ListView渲染完成后的正确时机才关闭RefreshControl.

Ref的挂载片段如下:

<RefreshControl
    refreshing={false}
    onRefresh={this._onRefresh}
    colors={[standardColor]}
    ref={ ref => this.refreshControl = ref}
/>

现在, 上拉加载更多手势与RefreshControl出现之间已经没有任何延迟了

实际上, ListView即使是用不同数据重绘三次, 也还有优化的余地, 可以不重绘完全一样的Row, 但是这样还是会有额外的Diff开销, 因此使用setNativeProps优化RefreshControl是一定要做的, 立竿见影.

去除ListView中描述上拉加载更多下拉刷新的状态

RN的最新版本中, ListView配合RefreshControl的情况下, 已经只能同时触发一次上拉加载更多或者下拉刷新, 因此描述这两种状态的state已经完全没有必要, 更别说将其移到Redux中了.

正确使用ListView.DataSource

如果你的ListView加载第二页和加载第三页的速度差别很大, 那么可能无意间犯下了一个错误: 没有正确使用rowHasChanged

可以在rowHasChanged函数中打印一下结果, 如果没有任何输出, 那么就可以肯定地说,ListView.DataSource用错了.

官方文档其实略有误导性, cloneWithRows并不是完全抛弃以前的数据, 它会将之前的数据和现在传入的数据用rowHasChanged进行对比, false则不进行重绘此行. 因此, 必须要做到每次更新ListView数据时也更新DataSource, 代码如下:

// 初始化
const dataSource = new ListView.DataSource({
  rowHasChanged: (row1, row2) => row1 !== row2,
});

// 更新dataSource
dataSource = dataSource.cloneWithRows(/*需要ListView展示的数据*/);

// 最后将dataSource传给ListView

如上, 每次更新数据时一定要将其写回数据源, 否则ListView根本无法调用到rowHasChanged进行对比, 毕竟原始数据是空的.

另外, 没有必要将DataSource放入组件的State中, 因为setState会触发组件生命周期, 造成一次虽然会被Diff跳过但是仍然会有深度对比延迟的组件重绘

正确使用rowHasChanged

rowHasChanged: (row1, row2) => return row1 !== row2

上面这种rowHasChanged也是另外一种浪费, 它将依次对比对象的第一层的全部值, JSON形式的API提供的数据一般都会有不被展示的值, 这也会被拿来对比造成性能损失.

因此, 最好的办法是仅仅对比ListView中需要展示的而且可能会变更的属性(例如回复数), 还有数据的id值.

假设ListView用到的属性全部都不可能变更, 例如创建者和标题, 那么, 就可以仅仅对比id, 获得非常大的对比提升了

弃用第三方动画组件

React-Native现在已经有了更好的Animated和Layout Animation, 无需优化就能达到Native级别的动画体验

以react-tween-state为例, 一个spring动画掉帧非常严重, 无论是用requestAnimationFrame还是runAfterInteraction优化, 效果都很不理想

class AnimatedDemo extends Components{
    constructor(props){
        super(props);

        this.state = {
            leftValue = new Animated.Value(0);
        }
    }

    componentDidMount(){
        Animated.spring(
            this.state.leftValue,
            {toValue: 100, duration: 200 }
        )
    }

    render(){

        return (
        <Animated.View
            style={{ left: this.state.leftValue , width:100, height:100, background:"#eee"}}
        >

        </Animated.View>
)

}

如上是使用Animated的一个简单Demo, 效果是一个方块从0滑动到200, 运动曲线为Spring类型

需要注意的是, Animated.View这种经过包装的组件必须写在render方法的return后面, 这会将包装组件及其中Animated.Value值转换成原生组件和值. 注意如下语句

style={{ left: this.state.leftValue

this.state.leftValue即Animated.Value的一个实例, 它实际上是一个对象, 并不是style的left对应的Number值, 必须与包装组件共同搭配才能正确转换.

Animated配合强悍的PanResponder手势系统, 可以做出Native级别的动画, 而第三方JS组件是怎么也不可能优化到这种程度的

Redux相关

Redux的思维确实很不错, 但在真实动手这种dirty job的时候反而容易陷入很多的误区, 造成性能的严重下降.

状态触发顺序分析

可以使用redux-logger中间件来输出每次dispatch的action, 配合组件内render方法中的console.log, 通过app开发菜单中的Enable Remote JS Debugging将所有日志全部输出到远程浏览器的console中

如上图, 这样所有的action触发与组件render顺序便一目了然, 可以据此检查组件重绘的数量和顺序是否正确

注意, 如果State中包含大量数据, 那么输出数据到控制台将导致React Native动画帧率的大幅下降, 请仅仅在分析性能时打开redux-logger

评估State设计是否合理

仅仅只有一小部分状态才该放在Redux的State状态树中

例如, 描述当前页面展示数据的数组就不应该放在Redux的State中, 而该就近放在组件的State值中. 状态管理是为了解决父子间甚至兄弟组件间的通信问题, 按照这种思想, 组件私有的状态不会被外部组件所用, 因此就不应该将其纳入Redux的状态树

一旦将某个组件的私有状态转移到全局的State中, 那么此组件内部的dispatch很可能会错误地触发数量远超你想象的组件重绘, 导致帧率严重下降

慎重选择容器组件和展示组件

绝对不该将所有的State在非常顶层的组件中connect起来, 应该慎重考虑, 仅仅在展示组件的上一层或上两层的父组件才是更好的选择

以根组件为例, 在此处挂载的State以props的形式继承给子组件, 子组件的任意dispatch都将触发全部组件树的重绘, 你需要写非常多的shouldComponentUpdate和 mapStateToProp的逻辑代码, 才能弥补回一部分性能损失

善用combineReducers进行State分离

从Store.getState()的效果来说, combineReducers好像不怎么样, 仅仅是将不同State以不同的名称再次组合成一个新对象, 除此之外貌似没有任何效果.

实际上, 这是一种错误的看法. combineReducers与connect组件共同发挥, 就可以完成Redux内部的shouldComponentUpdate方法

/*Status: {
 *    app: {
 *      segmentedIndex: 0,
 *    },
 *    other: {
 *      topics: []
 *      topicPageNum: 0,
 *    },
 *}
 */

function mapStateToProps(state) {
    return {
      app: state.app,
    };
}

export default connect(
  mapStateToProps
)(Toolbar);

如上mapStateToProps函数配合connect, 会将other这个State过滤掉, 此组件将仅对比app这个State, 做到分离效果

如果你没有感受到combineReducers的好处, 很可能你的mapStateToProps仅仅是下面这种未做任何过滤的函数

function mapStateToProps(state) {
    return state;
}

即使你只有一个单一State, 也应该用combineReducers包装起来, 配合mapStateToProps可以省略掉很多不必要的组件重绘

本文链接:https://smallpath.me/post/react-native-opti

-- EOF --