关于程序员工作能力的思考

这个题目看起来就有点不着边,所以就当随便说说吧。昨天下午接到阿里游戏hr的电话问有没兴趣考虑亚博科技的cocos开发职位,我觉得可以了解下,于是对方说后期会有技术人员来电话沟通,挂了电话后,想想有大半年没有再接触cocos了,如果直接进行电话技术面,问起一些技术细节,我多半都不记得了,如果以此判定我技术不过关,我肯定是不承认的,但如果站在对方的角度想,你要求担任项目技术负责人的职位,却说不出个所以然来,对方又如何知道你的深浅呢。

程序员的面试一直是个很纠结的问题,网上相关的争论很多,追根到底就是如何判断一个程序员的工作能力。

先拿我自己的实际情况来说好了,我今年三月底到新的公司,工作是参与一个使用ReactNative进行app应用的开发,在此之前我从来没接触过RN,如果我直接冲着这个职位进来面试,那应该根本没有任何通过的希望,我本来面试的职位是cocos前端开发,阴错阳差给了我这个RN开发的职位。入职之后,首先因为我对NDK编译,C++,Java,JavaScript语言都比较熟,于是负责了一些原生语言和C++和JS之间API的设计实现。之后我独立负责完成了RN项目的Bundle拆分工作,这个工作需要对RN的源代码有一定的了解,我边看边学边实践,大概一个月的时间把现有的项目完成了bundle拆分。在入职后不久,看了下项目代码,因为原来的开发人员基本是原生开发人员转过来的,他们的JS代码和项目结构不是很规范,尤其是没有理解RN和web前端的数据流渲染方式的思想,所以提出了不少改进意见(大多数并没有落实下来,毕竟项目有点庞大,又有新的开发需求)。这是我这几个月做的事情,要说有多牛逼倒也不至于,但也不简单轻松,至少不谦虚的说我相信我是比身边的同事们都强一些,这也有工作业绩做证明,平时比较棘手的问题,需要跟踪深查出原因,或者可能修改源码,一般也交给我来解决。

一个程序员的工作能力,在工作一段时间后,大家有目共睹了,也就不需要再证明了。然而在面试时,要怎么表现出来就是个问题了。今年年初我打算换工作时,也面试过几次,感觉其实都不太好。有一次是跟一个科大师兄一起吃饭,看能不能进入他们的U3D游戏开发项目组,同桌的还有他带来的几个技术leader,实际上也是一次面试,但我为了避免自己紧张,就当作一个普通的聊天,然后就没有然后了,我后来想想,也许是他们觉得我说的话都太空了吧,比如因为我这几年一直做cocos,没有U3D的开发经验,我就说新技术的学习其实是很简单的事,我现在也会这么说,因为我就是这么认为的。我从没接触过RN,但这几个月后,我敢说对RN的理解比很多人都强。如果说RN比较简单的话,那Unity,UE这些引擎又能多复杂吗?不过是工具而已,真正难的应该是计算机图形学吧,但实际项目中未必用到这么高深,技术能力深入到这么底层的专家也不多。

我觉得程序员的能力之一就是项目的代码结构设计,基本上每个程序员都知道MVC,即使是刚毕业的应届生,也能说得头头是道,但去看一个个实际项目就发现惨不忍睹了,UI和数据和逻辑代码全都混杂在一起,有人说这是很多现实原因造成的,比如项目进度比较赶啊,比如一份代码多人维护之类的,但我觉得其实是态度问题,一个认真的程序员,在动手写代码之前,要进行足够的思考,来找出最好的方案,不能只是为了把功能实现就行了。一般来说工作了三五年的程序员,如果还掌握不了一个项目的结构设计的话,那肯定是不够认真,没有经过思考。而这些不太好说清楚的东西,其实才是程序员最有价值的能力。

除此之外,我认为程序员最重要的能力就是解决问题的能力。碰到一个问题,通过观察思考定位到原因,然后从根本上解决,是一个好的程序员应有的能力。有的程序员在碰到问题的时候,不去想办法定位原因,很多时候靠猜测和尝试,看看这样改行不行,那样改行不行,这样的话即使问题可能解决了,但自己都不知道怎么解决的,以后碰到同样的或者类似的问题,解决起来还是很费力。还有的程序员在解决问题的时候很肤浅,比如一个component位置不对,不管是什么原因就直接改它坐标好了,结果改完在一些情况下还是不对,一个问题反反复复的折腾,可能最后还是没能彻底解决。这两种人其实都是在现实中很容易看到的。

上面两个是暂时能想到的,当然还会有别的方面,但说起来是一件事情,归根到底就是认真两个字。一个有技术追求的程序员,一定会想尽一切办法提高自己,努力思考,勤总结。要成为顶尖top 1%的明星程序员很难,但要成为在周围环境中突出的优秀程序员却不难,只要有认真的态度。我的这些看法在跟科大师兄聊的那次也说过,结果也显而易见,虽然我不知道他是否认同,但至少没能让他决定让我进入项目组,所以面试的时候我想也没必要说这些,还是多聊项目和技术细节好了,提前准备一下。

说了这么多,都是想到哪说到哪,就当是一次聊天来看吧。

ReactNative之setState的同异步

官方文档中关于setState有一段说明

Think of setState() as a request rather than an immediate command to update the component. For better perceived performance, React may delay it, and then update several components in a single pass. React does not guarantee that the state changes are applied immediately.

setState() does not always immediately update the component. It may batch or defer the update until later. This makes reading this.state right after calling setState() a potential pitfall. Instead, use componentDidUpdate or a setState callback (setState(updater, callback)), either of which are guaranteed to fire after the update has been applied.

可以明确地看到,setState只是发起一个请求,要求改变state,并不一定会立即执行。如果在setState后立即获取state,有可能得到的还是赋值前的旧值。这里需要特别注意的是“不一定”和“有可能”,也就意味着有的情况下是异步执行的,有的情况下是同步。我们需要搞明白分别什么情况下会是同异步。看下面的例子

constructor(props){
    super(props);
    this.state={
        text:"123"
    }
}

componentWillUpdate(){
    console.log("guangy componentWillUpdate")
}

_onClick(){
    console.log("guangy before set state,now value " + this.state.text);
    let num = Math.floor(Math.random()*100);
    this.setState({text: ""+num });
    console.log("guangy after set state,now value " + this.state.text);
}

render(){
    console.log("guangy render.....")
    return(<View>
        <Text>{this.state.text}</Text>
        <Button title="A" onPress={
            ()=>{
                this._onClick();
            }
        }/>
        <Button title="B" onPress={
            ()=>{
                   setTimeout(()=>{
                    this._onClick();
                   }, 10);
            }
        }/>
    </View>);
}

当点击A按钮时,输出log为

guangy before set state,now value 123
guangy after set state,now value 123
guangy componentWillUpdate
guangy render.....

可以看到这里setState是异步执行了。

当点击B按钮时,输出log为

guangy before set state,now value 123
guangy componentWillUpdate
guangy render.....
guangy after set state,now value 80

很明显,这里setState是同步执行了。

除了setTimeout外,在DeviceEventEmitter的回调函数里,也是同步执行。这跟js的事件循环机制有关,render函数,setTimeout和DeviceEventEmitter的回调都是在事件循环结束时调用,所以此时调用setState触发render会是同步的,其余时候调用setState则是异步的。

在setState为异步时,要注意如果在一次事件循环中多次setState,后面的会覆盖掉前面的,因为这一次循环里所有的setState会集中到一次来处理。 然后就是注意不要在setState后立即获取state,如果想要获取新的state,应该在setState传入回调函数作为第二个参数,在这个回调函数里获取。

setState还有一种形式就是接受function作为第一个参数,形式为

this.setState((prevState, props)=>{
    return {xxx};
})

这样可以明确知道当前state是什么状态,然后返回新的state。

ReactNative之封装组件

在项目开发中有时候会需要封装一些自定义组件以便复用,本文稍微整理一下。

我们基于一个自定义组件进行封装,这样方便通过console观察函数的调用情况

class MyComponent extends Component{
    componentWillMount(){
        console.log("myComponent componentWillMount....");
    }
    componentDidMount(){
        console.log("myComponent componentDidMount....");
    }
    render(){
        return (<View>
            <Text>123</Text>
            <Text>456</Text>	
        </View>);
    }
}

如果基于特定某种组件封装,那实际上就是继承,在render的时候,调用父类的render()即可。例如

class HighComponent extends MyComponent{
    componentDidMount(){
        super.componentDidMount();
        console.log("HighComponent componentDidMount....");
    }
    render(){
        return(<View style={{width:100,height:100,backgroundColor:"red"}}>
            {super.render()}
        </View>)
    }
}

这里需要注意一点的就是分清子类是否需要重写父类的方法,如果不重写,则会直接使用父类的实现,就像上面的componentWillMount函数。如果重写,判断是否需要使用super调用父类的实现,比如上面的componentDidMount函数。

还有一种基于特定组件封装的办法,就是重写它的render方法,例如

import {cloneElement} from "react"
let originText = Text;
Text.prototype.render = function(...args){
    let text = originText.apply(this, args);
    return cloneElement(text, {style:[
        text.props.style,
        {color:"red"}	
    ]})
}

然后再使用Text,就发现字体颜色都变成红色了。这个cloneElement是react库里提供的一个方法,可以到源代码里查看了解如何使用

如果不基于特定组件,那写一个封装的函数,把需要被封装的Component传进去,例如

 function highComponent(Com){
    return ({children, ...props}) => {
        return(<View style={{width:100,height:100,backgroundColor:"red"}}>
            <Com {...props}>
                {children}
            </Com>
        </View>)
    }
}
const HighComponent = highComponent(MyComponent);

或者

function highComponent(Com){
    class Tmp extends Component{
        render(){
            return(<View style={{width:100,height:100,backgroundColor:"red"}}>
                <Com {...this.props}/>
            </View>)
        }
    }
    return Tmp;
}
const HighComponent = highComponent(MyComponent);

这个demo很简单,不论往函数里传什么Component,都会被装在一个我们自定义的View里面。

这两个写法效果是一样的,但后者比前者更强大一点,基于类可以实现更多功能。

2017/12/16添加:

需要注意在封装组件时,如果有内部使用的style,应该使用this.props.style和内部的进行组合,否则在使用这个封装组件时传入的style就不起作用了,对于上面例子里的HighComponent,下面设置的style不会起作用。

<HighComponent style={{marginLeft:50}} />

要想让传入的style生效,在封装时就要使用this.props.style

class HighComponent extends MyComponent{
    render(){
        return(<View style={[this.props.style, {width:100,height:100,backgroundColor:"red"}]}>
            {super.render()}
        </View>)
    }
}

ReactNative之ListView局部刷新

我们直接来看一个很简单的demo,一个listView和一个button,点击按钮后,随机一行的数字加100。通过观察log可以看到,每次点击按钮后,_renderRow会被重新调用25次,每个MyComponent的render函数都被重新调用了一次。我们在dataSource的rowHasChanged回调里打了log,却发现没有被调用。所以虽然只改变了一行数据,却刷新了整个listView。这样肯定对性能会造成影响。这篇文章记录了修复这个bug的流程,如果不想看的话可以直接跳到最后看解决方案。demo代码如下

class MyComponent extends Component{
    render(){
        console.log("guangy call render with tag " + this.props.tag);
        return <Text>{this.props.text}</Text>
    }
}
export default class TestList extends Component{
    constructor(props){
        super(props);
        this.state = {
            dataSource: new ListView.DataSource({
                rowHasChanged: (rowData1, rowData2) => {
                    console.log("guangy rowHasChanged")
                    return rowData1 !== rowData2;
                    },
            }),
            data:[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],
        }
    }

    _renderRow(rowData, sid, rid){
        console.log("guangy _renderRow");
        return (<View style={{width:360,height:40,alignItems:"center",justifyContent:"center"}}>
            <MyComponent tag={rid} text={rowData}/>
            </View>);
    }

    render(){
        return <View><ListView
            style={{
                	width:360,
                	height:600
            	}}
            dataSource={this.state.dataSource.cloneWithRows(this.state.data)}
            renderRow={this._renderRow.bind(this)}
            enableEmptySections={true}
        />
        <Button title="click" onPress={()=>{
            let arr = this.state.data;
            let len = arr.length;
            let index = Math.floor(Math.random() * len);
            arr[index] += 100;
            this.setState({data:arr});
        }}/>
        </View>
    }
}

既然是rowHasChanged不被调用,我们去源代码里找一下在哪里被用,然后就查到了ListViewDataSource的_calculateDirtyArrays方法,从方法名就能看出来,这是在计算哪些单元行变dirty了需要re-render。

dirty =
      !prevSectionsHash[sectionID] ||
      !prevRowsHash[sectionID][rowID] ||
      this._rowHasChanged(
        this._getRowData(prevDataBlob, sectionID, rowID),
        this._getRowData(this._dataBlob, sectionID, rowID),
      );

所以一旦prevSectionsHash[sectionID]为false或者prevRowsHash[sectionID][rowID]为false,_rowHasChanged回调就没机会被调用了。通过log也可以看到_calculateDirtyArrays函数传进来的实参prevSectionIDs和prevRowIDs是[]

调用这个函数的地方是ListViewDataSource的cloneWithRowsAndSections方法

newSource._calculateDirtyArrays(
  this._dataBlob,
  this.sectionIdentities,
  this.rowIdentities,
);

这里是创建了一个newSource,用来计算dirtyArrays的是原来的sectionIdentities和rowIdentities。看到这里就明白整个逻辑了,每次调用cloneWithRowsAndSections函数(调用cloneWithRows实质上也是一样),返回一个新的dataSource,然后计算需要re-render的单元行。

所以结论就是:要实现listView的局部刷新,关键是需要调用cloneWithRows或者cloneWithRowsAndSections来生成一个新的dataSource。将代码稍微改一下,测试就没问题了。

//this.setState({data:arr});
this.setState({dataSource:this.state.dataSource.cloneWithRows(arr)});

然后需要注意的是rowHasChanged回调函数,这个demo里使用的数据是简单数据类型,如果是复杂数据类型,简单的使用===就会有问题了,有可能实际数据内容没变化但引用变化了,导致不应该re-render但却触发了。或者实际数据内容变化了但是引用没变,导致应该re-render却没触发。简而言之就是复杂数据类型应该比较实际数据内容。

另外一个实现局部刷新的方案是将renderRow返回的Component进行封装,实现其shouldComponentUpdate接口。这个方法比较绕,所以最好还是使用ListView本身提供好的接口rowHasChanged。

这个例子很简单,所以一眼就能看出来问题。但如果项目使用了redux,问题就很隐蔽了。使用redux时,数据数组存储在store里,dataSource在页面里才创建,当数据发生变化时,redux触发setState修改的就是数组,而不是dataSource,导致整个listView都刷新了。这个情况下要局部刷新,首先在store里储存dataSource,reducer内数据更新时也更新dataSource,其次需要rowHasChanged函数不能简单的使用row1!==row2,而是要比较实际内容,因为redux基于imutable原则,即使内容不变,row1和row2也不是同一个对象了。

ReactNative之重叠区域触摸处理

在做界面时,写了一个弹窗界面,它有个全屏的半透明背景,只是一个普通的View,当弹窗弹出时,发现原来界面的所有触摸都失效了,就像这个半透明背景把触摸都吃掉了一样,按照原来用cocos的习惯,一个普通的View如果不去指定接受触摸,应该不会处理触摸,而是将触摸传递给下一层界面才对。

为了确定RN的触摸传递机制,写了个demo试验了一下。就不贴代码了,很简单,界面上写两个View分别是A和B,他们区域重叠且A被B覆盖,给他们都设定onStartShouldSetResponder接口,只有这个接口返回true,才会感受到触摸。可以观察到,无论B的onStartShouldSetResponder返回true还是false,当触摸发生在重叠区域时,A的onStartShouldSetResponder都不会被回调。这样结论就很明显了,同级组件如果有重叠区域,则重叠区域内触摸事件完全由后渲染组件接受,先渲染组件连是否接受触摸的回调都不会被调用,不可能有机会处理触摸

然后是父子节点,触摸父子节点的重叠区域,子节点的onStartShouldSetResponder会先被调用,如果子节点的onStartShouldSetResponder返回了true,那么父节点的onStartShouldSetResponder将不再被调用,也就是父节点没有机会响应触摸,如果子节点的onStartShouldSetResponder返回了false,那么父节点的onStartShouldSetResponder会被调用,此时子节点忽略触摸,交由父节点处理。

如果区域不重叠,那显然各干各的,如果重叠,则是遵循上面的原则。所以再看最开始那个例子,这个弹窗有个全屏背景,即使只是一个普通View,没有写任何触摸相关的代码,他也会把同级节点的触摸事件全部屏蔽了。如果一定要让触摸传递下去,可能只能想办法hack了,比如修改层级关系。

ES6中循环引用的坑

今天碰到一个很诡异的问题,在import一个模块后直接使用,结果就报红屏错误 undefined is not a function,当时很奇怪,明明那个模块export了很多东西,而且别的地方import又是正常的,为什么在这里import就出问题呢?用console打印出来,发现import进来的结果是个{}。代码大概就是

import Test from "./test"
Test.test();	//这里报错,说test是个undefined,所以不能当做方法调用

后来突然想到,可能是产生循环引用了,一查果然是这个原因。因为项目比较庞大,不小心还是很容易间接产生循环import。

查阅阮老师的ES6教程里有ES6关于循环import的说明。写一个简单的例子

//a.js
console.log("before import b")
import {b} from "./b"
console.log("b is " + b)
export let a = b+1;

//b.js
console.log("before import a")
import {a} from "./a"
console.log("a is " + a)
export let b = a+1;

结果是

before import a
a is undefined
before import b
b is NAN

这里有一个有趣的现象就是第一句输出并不是before import b,也就是虽然import语句在后面,但确会更早执行,当执行import b时,加载并运行b.js,从而第一句输出是before import a。

然后就是当运行b.js时,发现又需要import a.js,此时不会再去加载a.js了,而是认为整个a.js模块是{},所以a的值就是undefined了。可以通过以下代码验证

//b.js
import * as A from "./a"
console.dir(A)	//输出为{}

因为循环import一旦出现查找起来比较麻烦,经过了好多个中转,每个文件又都import了很多,很难找到是怎么导致循环的。一个避免出问题的方法就是少写立即执行的代码,尽量使用函数封装起来,需要的时候调用函数,就不会出错了。

对于像constants, enum, global等一些需要立即执行的模块,则手动确保不要产生循环即可。

ReactNative之给bundle命令增加参数

因为拆分bundle的需要,在使用react-native bundle命令打业务bundle时,需要不同bundle的module id不一样,如果直接打,它们的id都是从0开始递增的。为了解决这个问题,通过修改源代码,给react-native bundle命令增加了两个参数,指定打bundle时生成ModuleID的行为。

首先我们跟踪下react-native bundle命令运行的轨迹

cli功能的源代码位于node_modules/react-native/local-cli文件夹内,cliEntry.js的111行

return command.func(argv, config, passedOptions);

是执行命令行的入口,react-native bundle命令就是从这里进入了local-cli/bundle/bundle.js内的bundle方法,然后进入buildBundle.js内的buildBundle方法。

之后是node_modules/react-native/packager/react-packager/src/Server/index.js。从它的buildBundle方法,可以再跟踪到packager/react-packager/src/Bundler文件夹,这里的index.js内有一个方法createModuleIdFactory就是给每个模块生成一个module id的地方,我们把这个函数改一下就可以了。

因为参数传递比较复杂,我采用了一种比较很简单而且不会修改太多源代码的方法,就是在local-cli/bundle/bundle.js的bundle方法内读取我们增加的参数,存成全局变量,在需要用的地方读取即可。

function bundle(argv, config, args, packagerInstance) {
      let cid = args.cid;	//modified by guangy cid为common module的最大数字 add为需要加的数字
      let add = args.add;
      setCidAndAdd(cid, add);

      return bundleWithOutput(argv, config, args, undefined, packagerInstance);
}

存储和读取cid和add参数的代码为

let cid = 10000;
let add = 0;

export function setCidAndAdd(_cid, _add) {
    cid = parseInt(_cid);
    add = parseInt(_add);
}

export function getCidAndAdd(){
    return {cid, add}
}

最后在createModuleIdFactory方法里,通过调用getCidAndAdd获取cid和add的值来使用就可以了。

可以看到cli功能是用nodejs实现的,对于react-native bundle命令,从命令行读取什么参数,是否必带,是否有默认值等等在local-cli/bundle/bundleCommandLineArgs.js里进行配置,我们这次需要增加两个参数,只需要照已有的增加就行了。command里使用<>表示强制要求必须带这个参数。

{ //modified by guangy 增加两个参数 add和cid
command: '--cid <cid>',
description: 'common的最大Module id
  },{
command: '--add <add>',
description: '需要加的数字,比如10000,20000',
  },

然后还需要修改下node_modules/react-native/packager/react-packager/src/Server/index.js文件里的const bundleOpts = declareOpts({…})
增加

cid: {  //modified by guangy 增加两个参数
    type: 'string',
    required: true,
  },
  add: {
    type: 'string',
    required: true,
  },

即可。

如果要修改其他命令,也可以照着这个思路,先把流程捋顺了,不用每行代码都看明白,大概找到需要改动的地方就行了

ReactNative拆分bundle方案

为什么要拆分bundle?

我们知道RN项目中的js代码会被打成一整个bundle来加载执行,这个Bundle包含了我们自己写的业务代码和RN源代码。如果不进行拆分,我们在做热更新时,哪怕业务代码只更改了一行,也需要更新一整个bundle,其中RN源代码至少占用500k以上,如果使用了第三方库如redux等还会更多,这是很大的浪费。其次是可能一个项目中包含多个RN业务,这样加载它们各自的bundle都带有RN源代码以及第三方库,也就意味着这些公共代码被重复加载了很多次。

bundle内容

我们使用命令来打一个bundle进行观察,看bundle内都有什么

react-native bundle --entry-file index.android.js --platform android --dev false --bundle-output android/app/src/main/assets --assets-dest android/app/src/main/res

这里我们生成的是release版本的bundle,比较容易看出来三个部分。如果需要阅读代码,则把–dev设为true生成debug版本的bundle即可

第一个部分在release版本的bundle内大概占11行,他们是为js解释器注入了一些关键字和功能

global.require=_require
global.__d = define
var ErrorUtils = ...
Object.assign = ...
function guardedLoadModule(moduleId, module){	...

第二个部分占了整个bundle的绝大部分内容,它们都是以__d开头,我们以一个最短的举例

__d(function(t,n,c,i){"use strict";function o(t,n,c,i,o){}c.exports=o},22);

第三个部分是模块的调用,第二个部分是进行了模块的注册,如果想要代码执行,就必须调用模块,这是bundle内的最后两行

;require(65);
;require(0);

这个数字65根据不同的项目是不一样的。

如何拆分

bundle需要被拆分为RN源代码和业务代码。我们知道js代码的入口在index.android.js(或者index.ios.js),从这个入口文件起根据依赖关系,最后所有引用到的js文件被合并成了一整个bundle,我们给它命名total.bundle。然后我们把index.android.js里的代码注释掉,只保留对RN源代码的引用,例如

import "react"
import "react-native"

当然如果我们把一些第三方库例如redux也算入RN代码的话,也可以加上。这样生成的bundle我们命名为common.bundle。

我们观察一下common.bundle里第二部分定义的最后一个模块数字id为多少,在total.bundle内,从这个id以后的内容我们就可以拆分出来,生成新文件business.bundle,这就是我们的业务代码了。

然后我们处理一下common.bundle,我们找到它第二部分里定义模块0的那行代码和最后一行也就是require(0)去掉,这是入口文件,我们common bundle只需要引擎。而且我们的业务代码中入口文件也被定义为模块0,如果有两个模块0,则在分步加载时会有问题。

这样我们就把原来的total.bundle拆分成了common.bundle和business.bundle,之后就是如何使用了

合并加载

最简单的方案就是在加载bundle的时候,将这两个bundle一起读成字符串,然后合并成一个字符串,再进行加载。

我们可以在node_modules/react-native/ReactCommon/Instance.cpp里找到加载bundle的方法:loadScriptFromFile,在这里进行拼接即可。

这种方案可以减少热更新时bundle的大小,但不能优化加载bundle时的内存使用。

分步加载

这种方案需要对RN加载bundle的流程比较熟悉。我们在加载common.bundle时,不需要进入RN界面,也就是不需要启动一个ReactNativeActivity,而在源代码中,是在ReactNativeActivity的onCreate函数里加载js bundle并创建context的,所以我们将这些操作提出来。然后当需要加载业务代码business.bundle时,也不能简单的使用一个ReactNativeActivity,因为我们需要使用之前创建好的context,而不能重新创建。

具体操作是:我们使用一个Application implements ReactApplication,它会持有一个ReactNativeHost对象,这个对象host对象又会持有ReactInstanceManager对象,我们调用这个manager对象的createContextInBackground方法来加载common.bundle,创建了js context。

当我们需要加载business.bundle并进去RN界面时,我们使用一个普通的Activity,在它的onCreate函数里,构造一个ReactRootView,并设为contentView, 然后我们通过Application获取到host再获取到ReactInstanceManager,通过反射或者修改源代码,将manager绑定给这个ReactRootView,通过给它设置jsModuleName, 最后通过ReactInstanceManager获取CatalystInstance类,调用其loadScriptFromAssets或者loadScriptFromFile接口加载business.bundle即可。

这种方案将原来一整个Bundle加载分成了两步,在加载business.bundle时能够减少内存使用,提高加载性能。

结束

我在github上放了一个demo。做到让这个demo运行的程度并不意味着就万事大吉了,多个bundle之间模块的冲突,全局变量和方法冲突,图片路径问题,sourcemap解析等还有很多需要处理的问题,我们解决了不少,且目前已经在线上使用了。欢迎讨论交流。

ReactNative之生成android debug安装包

我们使用react-native init创建一个空的项目,想要让它在android设备上跑起来,官网教程给的方案就是使用react-native run-android命令开启联网调试,或者使用./gradlew assembleRelease来生成安装包。 前者必须依赖开发机开启联网服务,然后手机设置好服务器和端口并连接,否则要么屏幕一片空白,要么提示红屏报错。找到android/app/build/output/appDebug.apk可以看到安装包内没有Bundle等资源。后者需要配置签名。实际上如果我们想查看apk实际运行状况,可以很快生成一个debug安装包。

打包bundle

这是最重要的一步,在项目根目录下执行

react-native bundle --entry-file index.android.js --platform android --dev false --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res

这里最重要的是指定–bundle-output和–assets-dest两个参数。首先是–bundle-output,它必须放在android/app/src/main/assets目录下,如果该目录不存在就创建。必须指定文件名为index.android.bundle。 所有的图片资源必须放在android/app/src/main/res下,否则会无法找到图片资源而不能显示。

安装运行

到android目录下执行./gradlew installDebug即可

tips

在打包bundle那一步中,很多参数只是遵循默认的设置,比如bundle文件名叫index.android.bundle,位置在assets目录下,这些都可以按自己的需求来改,但前提是在java代码内也要做相应的调整,如果对这块不是很熟悉就按默认的来就行了。

不过–assets-dest目录必须在res目录下这个一定要遵守,因为Android系统要使用安装包内的资源,必须先转换成res id,如果放到别的文件夹下,没法转换,最后肯定找不到资源,也就没法显示图片了。

ReactNative中的Component的生命周期

本文介绍一下Component生命周期相关的一些函数,主要参考自官方文档

render

render函数是必须实现的,它用来渲染界面。一般会由this.props和this.state来控制如何显示。在这个函数里不能调用setState,否则会导致死循环,因为setState会导致render被调用。
render函数返回true,false,null,undefined都是合法的jsx语法,但它们都不会产生渲染。render函数应该避免太复杂耗时的操作。另外注意一下组件里如果想显示bool值,null,undefined时也需要显示转换成字符串才行。

mount

constructor

构造函数是最早被调用的,构造函数的第一句应该是super(props)。 state的初始化应该放在构造函数里。当然还有声明类的成员变量。

componentWillMount

当Component将要被加载时被调用,它在render之前被调用。在这个函数里执行setState的话,不会触发componentWillUpdate和render。因为render还没被调用,所以在这个函数里想通过ref获取子控件是不行的。componentWillMount为第一次render提供了准备数据的机会,我们可以放心的操作props和state。

componentWillMount也适合用来注册事件监听,假如有的事件在渲染时触发,那么在render前就注册显然更合适。componentWillMount的父节点会早于子节点被调用。

componentDidMount

当component已经被加载后调用,它在render之后被调用。所以在这个函数里执行setState会触发componentWillUpdate和render。对于同级节点,先渲染的componentDidMount会先被调用,对于父子节点,子节点的componentDidMount会比父节点先调用。前面说到render不应该调用setState来触发re-render,而componentWillMount调用setState又不会触发re-render,显然componentDidMount则完全没问题了,比如我们有一个component的尺寸未知,取决于另一个component的已知尺寸,就可以在componentDidMount里获取数据,计算好后setState来重新render。

constructor,componentWillMount和componentDidMount分别适合做什么事情

constructor显然适合做初始化,比如初始化state,成员变量,绑定成员函数等。而componentWillMount和componentDidMount的主要区别就是一个在render前,一个在render后。如果需要处理原始props和state,就应该放到componentWillMount中,比如使用props给state赋值,此外注册监听适合放在componentWillMount内。除此之外,大多数情况下的操作应该放到componentDidMount里,比如发起异步请求,开启计时器等等。

update

componentWillReceiveProps(nextProps)

当component的props被更新时被调用,但需要注意的是,update流程的所有回调触发的前提都是有update,也就是我们在父控件代码中改变子控件props但不触发update的话,这个回调也不会被调用的。例子很简单,比如

render(){
    return (<View>
        <TestComponent num=this._num>
        <Button onPress={()=>{this._num=999}}>
    </View>)
}

点击按钮改变了this._num,而this._num又作为TestComponent的props传递进去了,但因为并没有触发update,所以TestComponent的任何update回调都不会被触发。

另外需要注意,该回调被调用时,不一定props发生了变化,这里的没有发生变化有两种情形,一种是父控件没有改变子控件的props,例如父组件代码内调用setState刷新界面,此时子控件的componentWillReceiveProps也会被调用,但props没有发生变化。还有就是父控件传递过去的props是一个复杂数据类型,所以实际上是个引用,即使props发生了变化,this.props和nextProps也是相同的。

什么时候会用到这个回调呢?就是如果我们的state受props影响时,当传入的props发生了变化,我们在这里去修改state。在这里可以放心去执行setState

shouldComponentUpdate(nextProps, nextState)

通过这个回调函数返回true还是false来决定是否re-render。如果不重写的话会使用源代码的默认实现。默认实现可以参考源代码,它使用了fbjs/lib/shallowEqual.js内的shallowEqual方法来比较新旧props和state,只要props和state有一个改变了,就会触发re-render。 从shallowEqual代码看,首先是===强等判断,所以如果是同一个引用,那么不管内容怎么变,都认为是不变。例如

let old = this.state;
old.num=999;
this.setState(old);

这样不会触发re-render。然后是比较key-value,value的比较也是===强等。举个例子

this.state = {num:1};
...
this.setState({num:1});

这里虽然setState时将state变成了一个新的对象,但因为key-value完全一致,所以也不会导致re-render。如果value是复杂数据类型同理,例如

this.state = {obj:{num:1}}
...
let old = this.state.obj;
old.num = 999;
this.setState({obj:old})

这里虽然num变了,但并不会触发re-render。

以上是源代码中的默认实现,如果需要的话就重写通过限制一些re-render的触发条件,可以起到优化性能的作用。

componentWillUpdate(nextProps, nextState)

这个方法在每次re-render之前都会被调用,因为是在render之前,所以有点类似于componentWillMount,我们在这个回调里为下次render做好准备,通过this.props和this.state可以获取到当前的props和state,通过传进来的nextProps和nextState参数可以获得新的props和state。与componentWillMount不一样的是,在这里我们是可以操作UI的,但并不建议这么做,因为此时操作的是上次渲染的UI,它们有可能在下次render时就失效了。我们也不应该在这里调用setState,因为setState又会触发componentWillUpdate,这就造成了死循环,当然如果通过对nextProps或者nextState做判断是可以杜绝进入死循环的,但最好还是避免这样操作。

componentDidUpdate(prevProps, prevState)

正如前面说的componentWillUpdate对应componentWillMount,这里componentDidUpdate就对应着componentDidMount。它在render之后被调用,在这里就可以放心的获取和操作UI了。它的参数prevProps和prevState对应着componentWillUpdate里的this.props和this.state,而这个函数里的this.props和this.state就是当前的props和state,也就是componentWillUpdate里的nextProps和nextState。子节点的回调先于父节点被调用。在这里调用setState也需要非常小心,很可能会造成无限循环,如果确实需要的话,应该配合shouldComponentUpdate加以限制。如果我们需要对UI进行交互,比如获取某个UI的尺寸位置,这里是最合适的位置。

componentWillUnmount

当一个component不再被渲染时会被调用。例如

constructor(props){
    super(props);
    this.state={bVisible:true};
}

render(){
    return (<View>
        {this.state.bVisible ? <MyComponent /> : null} 
        <Button onPress={()=>{
            this.setState({bVisible:false})
        }}>
    </View>)
}

这个demo起始时MyComponent显示,点击按钮后不显示。不要误以为MyComponent只是被隐藏了,实际上它被销毁了,它所占用的内存都会被回收,此时它的componentWillUnmount会被调用。我们在componentWillMount或者componentDidMount里如果注册了一些监听,就需要在这里注销掉。与componentDidMount相反,父控件的componentWillUnmount会比子控件的先被调用。