2016-09-28 17:03:51

React Native:踩坑合集

最近用React Native写了个APP, 想看一看RN的交互和动画离真正的原生是否有差距, 没想到写到最后, 一半时间在写动画, 另外一半时间是在改RN的源码解决各种问题.

PSNINE

Android App for P9 written by React Native

仓库地址

这是我写的第三方APP, 用到的P9数据全是从官方安卓APP那里抓取出来的, 能用到的API全都用上了. 官方APP中详情页全部都是Webview, 不是很想用, 之后准备联系一下P9官方看能不能多给些API, 否则就得做解析HTML的苦力活了. 校招马上开始, 等拿到Offer之后再按计划做吧

更新: 重新抓了一下API, 发现P9官方APP的Token是客户端生成的, 估计官方不大可能会给权限, 还是得自己慢慢解析HTML了

解决的BUG列表

  • [x] PanResponder Outside ListView监听冲突
  • [x] TouchableNativeFeedback无法在点击时唤出涟漪
  • [x] BackAndroid无法删除已注册事件
  • [x] fetch拿不到Cookie
  • [x] Navigator push新页面时背景纯白

构建说明

注意, 本文中修改的react-native源码, 如果不是JS层而是Native层(例如java文件), 修改后必须从源码中重新构建react-native, 否则修改不会生效

从源码中构建react-native

http://facebook.github.io/react-native/docs/android-building-from-source.html

官方文档十分全面, 但有些地方仍然有坑, 注意如下几点即可

  • NDK版本
    • 必须使用ndk-r10e版本(下载地址在上面文档中), react-native不支持ndk的r11或r12版本
  • 不配置local.properties
    • sdk.dir和ndk.dir路径在windows环境下有路径转译的问题, 因此最好使用环境变量, 配置好ANDROID_HOMEANDROID_NDK即可, 不需要创建local.properties文件
  • 不需要clone或者下载react-native源码
    • 我们只是自己修复BUG, 只需要在npm install后, 直接修改/node_modules/react-native的源码, 之后构建即可

其他的可以完全遵循文档的流程. PSNINE没有第三方依赖, 不必考虑这部分的构建.

BUG: PanResponder Outside ListView

如果你直接从master分支构建app, 那么在首页的社区栏目下, 很容易发现如下BUG:

上拉ListView触发工具栏的隐藏后, ListView仍然在滑动的情况下, 如果此时立即下拉ListView, 工具栏将无法被拉出来正常显示

修正前如下, 可以看到在滑动中再反方向上滑时, 顶部工具栏完全没有反应:

修正后如下, 可以看到这次上滑已经有反应了:

在PSNINE中, PanResponder注册在ViewPagerAndroid上, 首页五个View都为ViewPagerAndroid的子组件, 因此这是属于PanResponder Outside ListView的手势冲突问题

解决方法

修改文件: /node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java

将Line #161的NativeGestureUtil.notifyNativeGestureStarted(this, ev);语句注释掉, 如下:

  @Override
  public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (!mScrollEnabled) {
      return false;
    }

    if (super.onInterceptTouchEvent(ev)) {
      NativeGestureUtil.notifyNativeGestureStarted(this, ev);  // 注释掉此行
      ReactScrollViewHelper.emitScrollBeginDragEvent(this);
      mDragging = true;
      enableFpsListener();
      return true;
    }

    return false;
  }

保存后, 从源码中构建react-native一次即可

BUG分析

这个严重BUG出现在所有使用ScrollResponder.js的组件中, 例如ListView, ScrollView, ViewPager等等, 影响范围很大.

它既不是react-nativeScrollResponder.js手势冲突导致的, 也不是reactResponderEventPlugin.js冒泡错误导致的.而是RCTScrollView.java中, 滑动中的触摸操作触发了Android原生手势事件,导致JS层注册的手势被直接夺取了权限.

此BUG出现时, 只会触发PanResponder的onPanResponderTerminate, 不会触发onPanResponderTerminationRequest, 与官方文档描述一致

修这个BUG的时候真是悲催, 我知道交互这方面RN很有可能出错, 但没想到RN的手势系统会这么坑. 我调试了整整三天

第一天追源码追到react-nativeScrollResponder.js, 将所有的方法都测了一遍都不行, 甚至把这个文件全部注释掉, 只导出一个必须的getDefaultState方法, 仍然不行.

第二天先是换了一种思路, 不用Responder监听来隐藏工具栏, 而是把隐藏工具栏的部分放到ListView的onScroll方法中. 效果也有, 但是会出现工具栏位置抖动的情况. 原因在于onScroll中上移了工具栏后, ListView也被上移了同等距离, 因此onScroll又会被触发一次错误距离的移动, 并且这个错误距离无法被估算, 因为Animated.value修改y值涉及到底层, 因此是异步的!

Hack方法失败了, 于是继续Debug, 这次追踪到reactResponderEventPlugin.js, 发现槽点无数: 事件冒泡将会直接被离得近的组件给截取到, 它根本不管更远的组件是否有更高的优先级. 但是, 在这个BUG情况下, 冒泡的结果反而是对的.现在JS层已经被全部排出了, 必须往更底层的JAVA走

第三天, 阅读ScrollView(RN中ListView继承自ScrollView)的JAVA源码, 还没看完就发现了NativeGestureUtil.notifyNativeGestureStarted这个可疑的方法, 仔细阅读相关的源码后发现, 它会将滑动时的触摸操作停止, 转而在当前View的TopView中触发原生手势, 槽点又来了, 这个TopView和React的源码一样, 居然还是先到先得, 没有优先级的判断, 因此直接在ListView中触发了原生手势而导致JS层手势被忽略!

一注释掉这条语句, 果然BUG被解决了. 并且, 去除这条语句目前我没有发现任何问题, 我甚至做了实现, ListView中嵌套ListView, 两个ListView的滑动还是没有问题.

BUG: TouchableNativeFeedback无法在点击时唤出涟漪

TouchableNativeFeedback一点也不Native

TouchableNativeFeedback在默认情况下, 点击时没有涟漪的, 如下图:

这个BUG真让人无语, 如果点击时没涟漪, 还要你TouchableNativeFeedback干什么?

现在大众的临时解决办法是将TouchableNativeFeedbackdelayPressIn设置为0, 但是这样滑动ListView的时候又会触发涟漪, 如下图:

这同样并不Native

使用下面的解决方法, 得到的最终效果如下:

解决方法

修改文件: node_modules/react-native/Libraries/Components/Touchable/Touchable.js

将Line #715的this.touchableHandlePress(e) 修改为this.touchableHandlePress(curState, nextState, signal,e)

再修改文件: node_modules/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.android.js

touchableHandlePress方法修改为如下语句:

  touchableHandlePress: function(curState, nextState, signal,e: Event) {

    if (curState==="RESPONDER_ACTIVE_LONG_PRESS_IN"&& nextState=== "NOT_RESPONDER" && signal=== "RESPONDER_RELEASE"){
      this.props.onPress && this.props.onPress(e);
      return;
    }else if(curState=== "RESPONDER_INACTIVE_PRESS_IN"&& nextState==="NOT_RESPONDER"&& signal===  "RESPONDER_RELEASE"){
      this._performSideEffectsForTransition( "NOT_RESPONDER", "RESPONDER_INACTIVE_PRESS_IN", "RESPONDER_GRANT",e);
      this._performSideEffectsForTransition( "RESPONDER_INACTIVE_PRESS_IN", "RESPONDER_ACTIVE_PRESS_IN", "DELAY",e);
      setTimeout(() => {
        this.props.onPress && this.props.onPress(e);
      }, 0);

      this.pressOutDelayTimeout = setTimeout(() => {
          this._performSideEffectsForTransition( "RESPONDER_ACTIVE_PRESS_IN", "RESPONDER_INACTIVE_PRESS_OUT", "LEAVE_PRESS_RECT",e);
      }, 1000);
      return;
    }

    this.props.onPress && this.props.onPress(e);
  },

BUG分析

这个BUG说白了就是react-native在瞬间点击时触发onPress后直接结束事件, 没有控制涟漪的出现和结束, 我们自己把这个过程加上就好

源码里面逻辑比较乱, 涉及好多状态机, 但是不用被吓到, 学过编译原理的都知道, 状态机的状态转换按定义来说,必须是同步操作, 不可能存在异步的状态转换图. 因此我们可以通过触发指定的状态来触发涟漪动画, 最后用setTimeout(()=>{},0)的形式,在动画结束的正确时机触发onPress

其实, 这里还有一个BUG: 点击的位置是不对的. 这个BUG不是我们这里修改造成的, 而是Flex布局导致的TouchableNativeFeedback的涟漪中心计算错误. 修复的PR已经Merge到RN的master分支了, 不久就会发布, 因此我这里不再做修改

BUG: BackAndroid无法删除已注册事件

如果直接构建master的源码, 很容易发现, 点击创建讨论页面的退出虚拟键, 将直接退出App而不是退出该页面. 原因在于这些页面并不是push到navigator中, 而是在首屏组件树中的.

创建新监听事件前必须清除已经存在的事件(因为源码中用的是ES6的Set有序列表而不是Array), 但是删除时需要事件函数, 因为BackAndroid的事件一般都与Navigator搭配使用在App最顶层, 而且React强调单向数据流, 因此子组件拿顶层组件的数据非常麻烦

解决方法

打开文件: /react-native/Libraries/Utilities/BackAndroid.android.js

在Line #83 添加如下代码:

  clearAllListeners: function(){
     _backPressSubscriptions.clear();
  }

BUG分析

这个问题说BUG其实也不算, 虽然BackAndroid.addEventListener返回值里已经有一个remove对象, 但是为了避免ES6循环引用时静态解析出错, 我还是修改了一下源码, 用以应付BackAndroid和Navigator强耦合的问题

这种解决方法仍然是治标不治本. 最好是将Set替换成Array, 再将其完全暴露出来, 这样可以将监听事件的权限完全交给开发者, 并且可以避免Set删除时十几毫秒的耗时(用户点击退出按键时如果恰好碰上这段时间,APP将直接退出而没有任何提示)

BUG: fetch拿不到Cookie

fetch访问时, 会自己默认带上该网站的cookie并会根据response的Set-cookie字段自行处理cookie, 就跟无壳浏览器表现一样. 但是现在不暴露cookie出来的话, 导致一些需要cookie做token验证的API我根本没法用

这个BUG出现在RN v0.32, 让我崩溃的是v0.31是没问题的.

2019-9-12日更新: 阅读源码后发现, fetch调用XMLHttpRequest, XMLHttpRequest调用NetworkingModule.java,最底层的NetworkingModule.java并没有解析cookie并传上来的语句, 而是调用了一个自动处理cookie的Handler(这里还是国人提的PR). Android层再往下走就是okhttp的实现了. 除此之外, 按照HTTP v1.1的定义, Set-cookie字段不能被当做header传给用户, 而应该被浏览器自行处理后再从header中过滤掉, 与fetch暴露出来的headers相符合. 因此这应该不算BUG

解决办法

查了fetch的源码没查到header中有处理cookie的地方, 猜测可能又要走到JAVA底层去. 后端出身的我干脆就自己搭了个node服务器, fetch访问一下服务器, 然后node返回JSON形式的请求头中的cookie

BUG: Navigator push新页面时背景纯白

解决方法

打开文件: react-native/Libraries/CustomComponents/Navigator.js

替换如下两个方法:

  _disableScene: function(sceneIndex) {
    let sceneConstructor = this._sceneRefs[sceneIndex];
    let currentRoute = this.state.routeStack[sceneIndex]
    let nextRoute = this.state.routeStack[sceneIndex + 1];
    if (nextRoute  && nextRoute.withoutAnimation && nextRoute.withoutAnimation === true || currentRoute.withoutAnimation) {
        sceneConstructor.setNativeProps({
          pointerEvents:"auto"
        });
    } else {
        sceneConstructor.setNativeProps(SCENE_DISABLED_NATIVE_PROPS);
    }
  },
  _enableScene: function(sceneIndex) {
    // First, determine what the defined styles are for scenes in this navigator
    var sceneStyle = flattenStyle([styles.baseScene, this.props.sceneStyle]);
    // Then restore the pointer events and top value for this scene
    var enabledSceneNativeProps = {
      pointerEvents: 'box-none',
      style: {
        top: sceneStyle.top,
        bottom: sceneStyle.bottom,
      },
    };
    if (sceneIndex !== this.state.transitionFromIndex &&
        sceneIndex !== this.state.presentedIndex) {
      // If we are not in a transition from this index, make sure opacity is 0
      // to prevent the enabled scene from flashing over the presented scene
      enabledSceneNativeProps.style.opacity = 0;
    }

    let routes = this.getCurrentRoutes();
    let shouldBeClickableUnderOtherRoutes = routes.some((value,index)=>{
      if (sceneIndex != index ){
        return false;
      }
      return typeof value.shouldBeClickableUnderOtherRoutes !='undefined' && value.shouldBeClickableUnderOtherRoutes == true;
    })

    if (shouldBeClickableUnderOtherRoutes && sceneIndex < this.state.presentedIndex){
      enabledSceneNativeProps.style.opacity = 1;
    }

    this._sceneRefs[sceneIndex] &&
      this._sceneRefs[sceneIndex].setNativeProps(enabledSceneNativeProps);
  },
  popN: function(n, cb) {
    invariant(typeof n === 'number', 'Must supply a number to popN');
    n = parseInt(n, 10);
    if (n <= 0 || this.state.presentedIndex - n < 0) {
      return;
    }
    var popIndex = this.state.presentedIndex - n;
    var presentedRoute = this.state.routeStack[this.state.presentedIndex];
    var popSceneConfig = this.props.configureScene(presentedRoute); // using the scene config of the currently presented view
    this._enableScene(popIndex);
    // This is needed because scene at the pop index may be transformed
    // with a configuration different from the configuration on the presented
    // route.
    this._clearTransformations(popIndex);
    this._emitWillFocus(this.state.routeStack[popIndex]);
    this._transitionTo(
      popIndex,
      popSceneConfig.defaultTransitionVelocity,
      null, // no spring jumping
      () => {
        this._cleanScenesPastIndex(popIndex);
        cb && cb()
      }
    );
  },

  /**
   * Transition back and unmount the current scene.
   */
  pop: function(cb) {
    if (this.state.transitionQueue.length) {
      // This is the workaround to prevent user from firing multiple `pop()`
      // calls that may pop the routes beyond the limit.
      // Because `this.state.presentedIndex` does not update until the
      // transition starts, we can't reliably use `this.state.presentedIndex`
      // to know whether we can safely keep popping the routes or not at this
      //  moment.
      return;
    }

    this.popN(1, cb);
  },

BUG分析

RN的navigator在默认情况下, 在push的组件外面还包了一层纯白底的View, 这导致了之前的组件被覆盖, 我们修改掉这里即可.

react-navigation

src/views/CardStackStyleInterpolator.js

// line 143
  const params = (props.navigation.state.routes[props.navigation.state.index] || {}).params || {}
  if (params.shouldSeeBackground === true) {
    if (index + 1 === props.navigation.state.index) {
      return {
        opacity: 1
      }
    }
  }

src/views/transitionCOnfigs.js

// line 93
  if (transitionConfigurer) {
    return {
      ...defaultConfig,
      ...transitionConfigurer(
        transitionProps,
        prevTransitionProps,
        isModal,
      ),
    };
  }

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

-- EOF --