React NativeでsetNativePropsした結果がどうNativeに反映されるのか JS編

React Native OSSもくもく会では、現在、こちらのIssueに取り組んでいます。
(メンバー: @ggtmtmgg, @takahi5, @__syumai, @binaryta)
https://github.com/facebook/react-native/issues/20971
内容としては、 setNativeProps を実行してTextInputの文字サイズを変更しようとした時に、予想通りの挙動をしてくれないという物でした。

setNativePropsとは

通常、ReactでComponentのpropsを更新したい場合は、該当のComponentに対して値を渡しているstateをsetStateで更新します。 setStateを実行すると、VDOMのツリー全体で、前回setStateした時点と差分があるかどうかの比較が走り、もし差分があれば、その部分のDOMツリーを再構成してrenderし直します。

一方、React Nativeを使う上で、stateは更新したくないが、Viewのプロパティだけを更新したい場合があります。 例えば、アニメーションするComponentを実装したいと考えた時、Viewのプロパティを更新するのにsetStateを使うと、Reactのrenderが連続的に走ってしまい、スムーズな動作が実現できなくなってしまいます。 setNativePropsを呼ぶと、Reactのstateを更新することなく、NativeのViewのプロパティのみを直接書き換えることが出来ます。

setNativePropsの実行例

<TextInput
  ref={this.inputRef}
  style={{
    fontSize: 20,
  }}
/>

<TouchableOpacity
  onPress={() => 
    this.inputRef.current.setNativeProps({
      style: {
        fontSize: 40,
      },
    });
  }
>
  <Text>Press me to change TextInput fontSize!</Text>
</TouchableOpacity>

今回問題となっている挙動

I'm trying to implement a dynamic fontSize on React Native based on the content size, but I hit a React Native limitation / bug.

You can change the fontSize multiple times before typing anything in the TextInput, but once you type something you cannot change it anymore. See it in action in the link below.

ざっくり翻訳

React Nativeで、コンテンツの大きさに応じて、動的にfontSizeを変更するものを実装しようとしています。しかし、React Nativeの制限、もしくはバグに引っかかってしまいました。

TextInputに文字を入力していない状態では、何度もfontSizeを変更することが出来ました。しかし、文字を入力した状態では一切変更出来なくなります。

Demo

本来予想される動作

setNativePropsする度、意図通りにTextInputのサイズが変わって欲しかった

setNativePropsの実装

setNativePropsがNativeのViewに反映されるまでの流れは、大きく2つに分けることが出来ます。

  1. JS側で変更するPropsを処理して、変更依頼をenqueueする。
  2. Native側で変更依頼をdequeueしてビューに反映させる。

今回はJS側の変更依頼をenqueueする処理に問題がないかを調べていきます。

JS側の実装

setNativePropsのJSの実装の実体は主に以下になります。

react-native/Libraries/Renderer/oss/ReactNativeRenderer-dev.js#L16165

setNativeProps: function(nativeProps) {
  ...
  var updatePayload = create(nativeProps, viewConfig.validAttributes);

  if (updatePayload != null) {
    UIManager.updateView(
      maybeInstance._nativeTag,
      viewConfig.uiViewClassName,
      updatePayload
    );
  }
}

この関数を以下の2つの部分に分けて実装を見ていきます。

  1. updatePayloadを作成する
  2. UIManager.updateViewにupdatePayloadを渡して変更依頼をenqueueする

1. updatePayloadを作成する

updatePayloadはcreateという関数で作られています。

var updatePayload = create(nativeProps, viewConfig.validAttributes);

そしてそのcreateではaddPropertiesを実行しています。

function create(props, validAttributes) {
  return addProperties(
    null, // updatePayload
    props,
    validAttributes
  );
}

そしてそのaddPropertiesでは引数にemptyObjectを渡した状態でdiffPropertiesを実行しています。

function addProperties(updatePayload, props, validAttributes) {
  // TODO: Fast path
  return diffProperties(updatePayload, emptyObject, props, validAttributes);
}

diffPropertiesはupdatePayloadを返す役割を持っており、実体はこの関数です。
だいぶ簡略化したのですがちょっと長いのでソースコード内に説明を記述していきます。

function diffProperties(updatePayload, prevProps, nextProps, validAttributes) {
  ...
  /* nextPropsに対してループ処理をします。
   * 今回のシチュエーションでは以下のオブジェクトが渡ってきます。
   * {
   *   style: { fontSize: 40 }
   * }
   * */
  for (var propKey in nextProps) {
    /* validAttributesは以下のようなオブジェクトです。
     * 
     * {
     *   ...
     *   autoCorrect: true
     *   backfaceVisibility: true
     *   backgroundColor: {diff: null, process: ƒ}
     *   blurOnSubmit: true
     *   ...
     * }
     * 
     * diffは値の変更を判定する関数です。
     * processは値をNative側に渡す前に処理する関数です。
     * backgroundColorは実数値に変換されます。
     */

    // もしattributeAttributesが値を持たないpropKeyであれば無視します
    attributeConfig = validAttributes[propKey];
    if (!attributeConfig) {
      continue; // not a valid native prop
    }

    ...

    if (prevProp === nextProp) {
      // もし値が変わってなくても無視します
      continue; // nothing changed
    }


    if (typeof attributeConfig !== "object") {
      /*
      * もしattributeConfigがただのtrueであれば
      * defaultDifferで差分の有無を確認し
      * 差分があればupdatePayloadに追加します。
      */
      if (defaultDiffer(prevProp, nextProp)) {
        (updatePayload || (updatePayload = {}))[propKey] = nextProp;
      }
    } else if (
      typeof attributeConfig.diff === "function" ||
      typeof attributeConfig.process === "function"
    ) {
      /*
       * diffがfunctionならdiffを利用して差分を確認する。
       * processがfunctionならprocessを利用して値を処理する。
       */
      var shouldUpdate =
        prevProp === undefined ||
        (typeof attributeConfig.diff === "function"
          ? attributeConfig.diff(prevProp, nextProp)
          : defaultDiffer(prevProp, nextProp));
      if (shouldUpdate) {
        var _nextValue =
          typeof attributeConfig.process === "function"
            ? attributeConfig.process(nextProp)
            : nextProp;
        (updatePayload || (updatePayload = {}))[propKey] = _nextValue;
      }
    } else {
      ...
      /*
       * もしattributeConfigがObjectじゃなければ、再帰的にdiffPropertiesを呼び出す。
       * 例えばpropKeyが "style" のような場合にここを通ります。
       */
      updatePayload = diffNestedProperty(
        updatePayload,
        prevProp,
        nextProp,
        attributeConfig
      );
      ...
    }
  }
  ...
  return updatePayload;
}

これで晴れてupdatePayloadを返せました。
ちなみに、setNativeProps({ style: { fontSize: 40 } });を実際に実行してみるとupdatePayloadは以下の値になりました。

{
 fontSize: 40
}

要求通りにupdatePayloadが作られていたことがわかりました。

2. UIManager.updateViewにupdatePayloadを渡す

if (updatePayload != null) {

  UIManager.updateView(
    maybeInstance._nativeTag,
    viewConfig.uiViewClassName,
    updatePayload
  );
}

UIManager.updateViewは内部的にenqueueNativeCallを呼んでいます。
enqueueNativeCallは(かなり簡略化すると)以下のような関数です。

enqueueNativeCall(
    moduleID: number,
    methodID: number,
    params: any[],
    onFail: ?Function,
    onSucc: ?Function,
  ) {
  ...
  this._queue[MODULE_IDS].push(moduleID);
  this._queue[METHOD_IDS].push(methodID);
  ...
  this._queue[PARAMS].push(params);
  ...
}

enqueueNativeCallはキューにmoduleId, methodId, paramsを積んでいて、
UIManager.updateViewは、enqueueNativeCallで下記の値をキューに積んでいました。

{
  moduleID: 41,
  methodID: 6,
  params: updatePayload
}

まとめ

setNativePropsの実装を追い掛けた結果、渡したい値がちゃんと作られて、enqueueNativeCallでキューに積まれていることが確認出来ました。
JavaScript側の実装には問題が無さそうと言うことがわかったので、次は、Native側の実装(今回は、iOS)を追い掛けてみようと思います。