前言
最近一段时间都在折腾ReactNative,经过两年发展,RN已经相对成熟,应用的性能和流畅度并不比原生逊色多少,并且一次开发不仅仅可以作用于Android,iOS平台,还可以兼容到MacOS和Windows平台,还是很值得一学的。且js本身就是一门动态语言,再加上JSX语法允许把逻辑和UI组合在一起,再配合高效的操作符和函数式编程,开发体验也很不错。但是在debug方面还是不及原生方便,错误提示和定位不够明确,一些非逻辑性的问题(比如环境,配置问题等)也很让人头疼。这篇文章主要是对最近一段时间做ReactNative和Redux开发的一些总结,主要包括三方面内容:
- ReactNative、Redux的一些踩坑和心得
- 基于protobuf.js和socket封装通信层
- js和native互相调用的总结
ReactNative、Redux踩坑和心得
0.增加新依赖或去除旧依赖后需要重启Packager,否则会报could not resolve all dependencies for configuration ':app:debugAPKCopy'
,入门踩的第一个坑 =.=
使用npm start -- --reset-cache
指令重启 或者ctrl/cmd + C关进程
1.当自己封装react-native的Android原生库,且该库还依赖了aar文件时,需要在当前react-native工程中额外指明aar所在路径。比如aar文件在react-native-ucloudsdk
这个封装库的android/libs
目录下,则在ReactNativeProject/android/build.gradle
中添加以下标注的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13
| allprojects { repositories { mavenLocal() jcenter() maven { url "$rootDir/../node_modules/react-native/android" } //添加如下部分,指明依赖路径,否则找不到aar flatDir{ dirs "$rootDir/../node_modules/react-native-ucloudsdk/android/libs" } } }
|
2.当0.45.x版本的react-native编译iOS时会报错,需要回退到0.44.x版本才可以编译通过
报错内容":CFBundleIdentifier", Does Not Exist
package.json中需要替换的依赖:
1 2
| "react": "^16.0.0-alpha.12", "react-native": "^0.45.1",
|
替换为
1 2
| "react": "^16.0.0-alpha.6", "react-native": "^0.44.3",
|
下载旧版依赖,清理缓存后再次执行react-native run-ios
即可编译通过
3.react-native中存在负责计算的JS线程和负责绘制的UI线程,大量的console.log
也会拖慢JS线程造成响应迟钝,打包release版时注意去除不必要的console.log
4.基于react-navigation
的换肤策略。虽然redux作为全局状态管理器可以作用于各组件,但大部分应用的title使用的应该都是路由框架react-navigation自带的组件,我们没办法直接接触到这个组件。调查后发现StackNavigator
具备一个可以传递到所有页面的属性screenProps
,借助这个属性可以达成我们改变所有页面title样式的目的,直接将订阅了store的属性传递给RootNavigator
的screenProps
即可。之后在每个页面的navigationOptions
中都能收到传递来的screenProps
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class App extends Component { render() { return ( <RootNavigator screenProps={this.props.screenProps}/> ); } } const mapStateToProps = (state) => { return { screenProps: state.themes.currentTheme, } };
|
5.redux的mapStateToProps
函数中收到的state是经过reduce类名包装的
1 2 3 4 5 6 7
| const mapStateToProps = (state) => { return { currentSong: state.songs.currentSong, playMode: state.songs.playMode, isPlaying: state.songs.isPlaying } };
|
我曾写成如下形式,状态更新后却没有re-render,debug了很久:
1 2 3 4 5 6 7
| const mapStateToProps = (state) => { return { currentSong: state.currentSong, playMode: state.playMode, isPlaying: state.isPlaying } };
|
6.注意组件的拆分粒度,如果一个组件可以被拆分成多个组件则尽量拆分,这样redux刷新props时只刷新需要的组件即可,否则重绘一个未拆分的大组件可能会造成不必要的开销。比如MoeFM中的播放界面组件PlayerUI
就被拆分成了数个小组件,每个按键都是一个独立的组件。在拆分之前每当props刷新时界面都会闪烁,这是因为每个小组件需要监听的props都注册在大组件中,刷新频繁,且每次刷新中还包含了一些不必要的成分。
7.ReactNative中的动画是没有infinite(无限执行)属性的,想让动画持续执行只能在动画执行结束后设回初值再重新执行,start
方法中的回调会在动画结束后调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| runAnimation(value) { this.isRunning = true; this.state.rotateAnim.setValue(value); Animated.timing( this.state.rotateAnim, { toValue: 360, easing: Easing.linear, duration: 20000 * (360 - value) / 360, } ).start((event) => { if (event.finished) { this.runAnimation(0); } }); }
|
8.当自己封装原生控件时如果出现has no proptype for native prop accessibilitylabel of native type String
这种错误,是由于缺少了一些基本属性,添加ViewPropTypes
即可
1 2 3 4 5 6
| import { ViewPropTypes } from "react-native"; propTypes: { ...ViewPropTypes, myProp: PropTypes.string }
|
9.redux中的reducer是纯函数(即每次输入相同的内容必然可以得到相同的结果),为了保证它的纯净性只能用它来更新state,不应该做额外的逻辑处理。如果在state发生变更的前后需要插入额外的逻辑,则应该通过middleware(中间件)来完成,在中间件中能够获得store,action,action前的state,action后的state,足以处理复杂的逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13
| const playerMiddleware = store => next => action => { const thisState = store.getState(); const result = next(action); const newState = store.getState(); const actionType = String(action.type); if (newState === thisState) { return result; } switch (actionType) { case ADD_SONGS: break; } }
|
ReactNative上搭建基于Protobuf.js+Socket的通信模块
之前在Android端上实现过基于protobuf协议的socket通信,最近在ReactNative上也做了这么一次实践。protobuf在js上的实现使用的是protobuf.js库,socket使用的是react-native-tcp这么一个封装了Android与iOS原生Socket的库,先说下几个坑点:
- ReactNative只能加载.js和.json,不能直接加载.proto文件,所以在使用protobuf.js时需要先用该库提供的Command Line工具,把.proto文件转换成.json格式,然后再加载并读取json。
- ReactNative自带的Websocket模块和主流开源库socket.io-client都只能以websocket的方式和服务端连接。如果服务端不支持websocket协议,那么还是需要用react-native-tcp来做通信,因为这个库导出了Android和iOS的原生Socket。
- react-native-tcp直接安装下来可能会报错,可以参考issue-42的方式解决。该库没有提供API文档,因为它的API直接参考了node.js的net模块,参考net模块文档即可
然后开始搭建通信模块,首先是编写.proto文件
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
| // BaseMsg.proto syntax = "proto3"; enum MSG_TYPE{ MSG_TYPE_START = 0; eSignal = 0x00000001; ePChannelMediaMsg = 0x00001000; eRequestLoginPMedia = 0x00001001; eReturnLoginPMedia = 0x00001002; } // PChannelMediaMsg.proto syntax = "proto3"; package PChannelMediaMsg; import "BaseMsg.proto"; message RequestLoginPMedia { MSG_TYPE msg = 1;// [default = eRequestLoginPMedia]; uint32 userid = 2; } message ReturnLoginPMedia{ MSG_TYPE msg = 1;// [default = eReturnLoginPMedia]; uint32 userid = 2; }
|
npm install -g protobufjs
全局安装protobufjs,执行转化命令pbjs -t json BaseMsg.proto PChannelMediaMsg.proto > bundle.json
得到bundle.json文件。
然后专门编写一个模块解析protobuf文件,并把解析结果都记录到Map中,方便之后使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import MsgType from './MsgType' let Proto = require('./bundle.json'); let ProtobufRoot = require('protobufjs').Root; let root = ProtobufRoot.fromJSON(Proto); const MessageMap = new Map(); let BaseMsg = root.lookupType("BaseMsg"); MessageMap.set('Base', BaseMsg); let RequestLoginPMedia = root.lookupType("PChannelMediaMsg.RequestLoginPMedia"); MessageMap.set(MsgType.eRequestLoginPMedia, RequestLoginPMedia); let ReturnLoginPMedia = root.lookupType("PChannelMediaMsg.ReturnLoginPMedia"); MessageMap.set(MsgType.eReturnLoginPMedia, ReturnLoginPMedia); export default MessageMap;
|
最后编写Socket模块,主要包括连接、断开、发送、接收、维持心跳包、附加与解析头部字段这些功能
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
| import MessageMap from './Protobuf' let net = require('react-native-tcp'); let client = null; let callback = null; let beatInterval = null; let recvData = new Uint8Array(); let isConnect = false; const options = { host: '192.168.1.1', port: '60000', }; const beat = new Uint8Array([5,3,3,0,1,2]); const HEART_BEAT_RATE = 1000 * 60; const RECONNECT_RATE = 1000 * 2; function parseRecvData(data) { let BaseMsg = MessageMap.get('Base'); let type = BaseMsg.toObject(BaseMsg.decode(data)).msg; let Message = MessageMap.get(type); if (Message === undefined) return; let obj = Message.toObject(Message.decode(data)); if (callback !== null) { callback(type, obj); } } const connect = () => { isConnect = true; client = net.createConnection(options, () => { if (callback !== null) { callback('connect', null); } beatInterval = setInterval(() => { client.write(new Buffer(beat)); }, HEART_BEAT_RATE) }); client.on('error', function(error) { clearInterval(beatInterval); if (isConnect) { setTimeout(() => connect(), RECONNECT_RATE); } }); client.on('close', function() { clearInterval(beatInterval); if (isConnect) { setTimeout(() => connect(), RECONNECT_RATE); } }); client.on('data', function(data) { let lastData = recvData; recvData = new Uint8Array(lastData.length + data.length); recvData.set(lastData); recvData.set(data, lastData.length); while (recvData.length > 4) { let head = new Uint8Array(4); head[0] = recvData[0]; head[1] = recvData[1]; head[2] = 0; head[3] = 0; let length = new DataView(head.reverse().buffer).getInt32(0, false); if (recvData.length - 4 < length) { break; } else { for(let i = 0;i < 4; i++) { recvData = recvData.slice(1); } } let msg = []; for (let i = 0; i < length ; i++) { msg[i] = recvData[0]; recvData = recvData.slice(1); } parseRecvData(msg); } }); }; const disconnect = () => { isConnect = false; client.destroy(); }; const send = (obj) => { let Message = MessageMap.get(obj.msg); let data = Message.encode(Message.fromObject(obj)).finish(); let head = new Uint8Array(4); new DataView(head.buffer).setInt32(0, data.length); head = head.reverse(); let msg = new Uint8Array(head.length + data.length); msg.set(head); msg.set(data, head.length); client.write(new Buffer(msg.buffer)); }; const setCallback = (on) => { callback = on; }; export { connect, disconnect, send, setCallback };
|
ReactNative中原生层与js层互发消息的几种姿势
ReactNative允许我们编写Native层的代码,来应对一些高性能、多线程的场景或是ReactNative尚未实现的一些功能。可以通过继承SimpleViewManager<T>
类来编写原生UI组件、继承ReactContextBaseJavaModule
类来编写原生功能模块。具体写法可以看文档,这里主要梳理一下当写好Native层后,如何实现以下消息的传递:
- JS -> 原生模块
- 原生模块 -> JS
- JS -> 原生UI组件
- 原生UI组件 -> JS
JS -> 原生模块
原生模块在和JS层的通信上要比原生UI组件方便不少,直接用ReactNative提供的@ReactMethod
就可以导出供JS层调用的方法了
1 2 3 4 5
| @ReactMethod public void show(String message, int duration) { Toast.makeText(getReactApplicationContext(), message, duration).show(); }
|
1 2 3
| import ToastAndroid from './ToastAndroid'; ToastAndroid.show('Awesome', ToastAndroid.SHORT);
|
原生模块 -> JS
通过Callback
类的invoke
方法来实现原生模块向JS层发消息。JS层在Callback参数对应的位置传入函数,当invoke
方法触发后会触发对应函数,并提供参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @ReactMethod public void measureLayout( int tag, int ancestorTag, Callback errorCallback, Callback successCallback) { try { measureLayout(tag, ancestorTag, mMeasureBuffer); float relativeX = PixelUtil.toDIPFromPixel(mMeasureBuffer[0]); float relativeY = PixelUtil.toDIPFromPixel(mMeasureBuffer[1]); float width = PixelUtil.toDIPFromPixel(mMeasureBuffer[2]); float height = PixelUtil.toDIPFromPixel(mMeasureBuffer[3]); successCallback.invoke(relativeX, relativeY, width, height); } catch (IllegalViewOperationException e) { errorCallback.invoke(e.getMessage()); } }
|
1 2 3 4 5 6 7 8 9 10 11
| UIManager.measureLayout( 100, 100, (msg) => { console.log(msg); }, (x, y, width, height) => { console.log(x + ':' + y + ':' + width + ':' + height); } );
|
JS -> 原生UI组件
原生UI组件在和JS层的通信上要相对复杂一些,需要重写SimpleViewManager
类的getCommandsMap
和receiveCommand
方法。getCommandsMap
定义了方法名和指令序号的映射,receiveCommand
定义了收到每种指令序号时需要执行的原生操作。
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
| private static final int CONNECT = 0; private static final int DESTROY = 1; private static final int SEND_JSON_TO_JS = 2; @Override public Map<String,Integer> getCommandsMap() { Log.d("React"," View manager getCommandsMap:"); return MapBuilder.of( "connect", CONNECT, "destroy", DESTROY, "sendJsonToJS", SEND_JSON_TO_JS); } @Override public void receiveCommand(WebBridge root, int commandId, @Nullable ReadableArray args) { Assertions.assertNotNull(root); Assertions.assertNotNull(args); switch (commandId) { case CONNECT: { if (args != null) { root.loadUrl(args.getString(0)); } break; } case DESTROY: { if (root != null) { root.setWebViewClient(null); root.removeAllViews(); root.destroy(); } break; } case SEND_JSON_TO_JS: { if (root != null) { String call = null; if (args != null) { call = "javascript:controller.getJsonFrom(" + args.getString(0) + ", " + args.getString(1) + ")"; } root.loadUrl(call); } break; } default: throw new IllegalArgumentException(String.format( "Unsupported command %d received by %s.", commandId, getClass().getSimpleName())); } }
|
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
| import {Component, PropTypes } from 'react'; import { requireNativeComponent, ViewPropTypes, UIManager, findNodeHandle } from 'react-native'; import * as React from "react"; const RTCWebBridge = requireNativeComponent('RTCWebBridge', WebBridge, { nativeOnly: { onChange: true } }); export default class WebBridge extends Component { constructor() { super(); this.onChange = this.onChange.bind(this); } componentDidMount() { this.mapViewHandle = findNodeHandle(this.mapViewRef); } onChange(event) { if (this.props.onJsToPhone) { this.props.onJsToPhone({ cmd: event.nativeEvent.cmd, json: event.nativeEvent.json, }) } } render() { return ( <RTCWebBridge {...this.props} onChange={this.onChange} ref={(mv) => this.mapViewRef = mv} /> ); } connect(url) { UIManager.dispatchViewManagerCommand( this.mapViewHandle, UIManager.RTCWebBridge.Commands.connect, [url], ); } destroy() { UIManager.dispatchViewManagerCommand( this.mapViewHandle, UIManager.RTCWebBridge.Commands.destroy, [], ); } sendJsonToJS(cmd, json) { UIManager.dispatchViewManagerCommand( this.mapViewHandle, UIManager.RTCWebBridge.Commands.sendJsonToJS, [cmd, json], ); } } WebBridge.propTypes = { ...ViewPropTypes, };
|
在JS层为了方便使用,将之前写的原生UI组件RTCWebBridge
又额外包装成了一个新组件WebBridge
,对外提供了connect
、destroy
等一系列方法。
可以看到当JS层想调用原生UI组件的方法时,实际是依靠UIManager.dispatchViewManagerCommand
方法来完成的。这个方法需要传入3个参数,第一个参数是原生UI组件的引用,第二个参数是我们之前在Native层的getCommandsMap
中定义的方法名,第三个参数是本次方法调用中需要用到的参数。当发生一次dispatchViewManagerCommand
操作时,被指定的原生UI组件会先从CommandsMap
取指令序号,然后把指令序号和参数一起传到receiveCommand
里完成本次方法调用。由此实现了JS -> 原生UI组件的消息传递。
原生UI组件 -> JS
原生UI组件向JS传递消息时,需要把参数封装到Event
里,然后将该Event
作为一个topChange
事件发射出去。
1 2 3 4 5 6 7 8 9
| WritableMap event = Arguments.createMap(); event.putString("message", "MyMessage"); ReactContext reactContext = (ReactContext)getContext(); reactContext.getJSModule(RCTEventEmitter.class).receiveEvent( getId(), "topChange", event); }
|
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
| class MyCustomView extends React.Component { constructor() { this._onChange = this._onChange.bind(this); } _onChange(event: Event) { if (!this.props.onChangeMessage) { return; } this.props.onChangeMessage(event.nativeEvent.message); } render() { return <RCTMyCustomView {...this.props} onChange={this._onChange} />; } } MyCustomView.propTypes = { /** * Callback that is called continuously when the user is dragging the map. */ onChangeMessage: React.PropTypes.func, ... }; var RCTMyCustomView = requireNativeComponent(`RCTMyCustomView`, MyCustomView, { nativeOnly: {onChange: true} });
|
JS层会在导出的原生UI组件的onChange
方法里收到topChange
事件。至于为什么topChange
会被映射成onChange
这个是官方事先定义好的,可以去看源码中的UIManagerModuleConstants.java
文件,在这里还能看到更多事件映射,不仅仅局限于topChange
。event.nativeEvent
就是我们之前在Native层封装的Event
,从中可以取到本次消息传递包含的参数,由此JS收到了Native发来的数据。
最后
最近基于ReactNative和Redux写了一个支持Android与iOS双平台的音乐播放器应用MoeFM,感兴趣的话可以去看看,欢迎提issue (๑´ㅂ`๑)
声明:本站所有文章均为原创或翻译,遵循署名-非商业性使用-禁止演绎 4.0 国际许可协议,如需转载请确保您对该协议有足够了解,并附上作者名(Est)及原贴地址