😽

React Nativeアプリに対するWebview導入入門

2021/12/24に公開

今回は小ネタとして、オンライン家庭教師マナリンク のReact Nativeアプリに対してWebviewを使った画面を実装する方法を示します。

Webviewを使う目的

アプリでは原則ネイティブの画面で実装したほうがパフォーマンス面やアクセシビリティの観点で望ましいですが、いくつかの理由でWebviewを採用することがありえます。

  • 既存のWebで実装された画面の流用で、再利用するほうがコストが掛からないため
  • HTMLなど、アプリ上でのレンダリングが難しいコンテンツを描画するため

メルカリさんの以下のブログを調査過程で拝見したのですが、こちらの記事では、Webviewを採用している理由として開発工数とクイックな対応というメリットを挙げています。

https://engineering.mercari.com/blog/entry/2019-12-21-000000/

このページが WebView で開発されていることには大きく二つの理由があり、一つは開発初期に開発人員の観点から WebView を採用したらいつの間にか規模が大きくなり他に選択肢がなくなったという歴史的経緯、もう一つはパートナーの API とのやり取りが多くクイックな対応が必要だというビジネス的な経緯です。

しかし、React NativeかつExpoを使っている場合では、Webと同じような開発スキルでアプリの画面開発はできるし、クイックな対応という観点でもOTAを活用することでストア経由のアップデートが不要で最新のソースコードをユーザーに配信できます。
そのため、Webviewを利用する理由はより限られたケースになります。

マナリンクでは、アプリのごく一部の機能においてWebviewを採用しました(2021年末現在、全体の画面数が100程度に対して6画面程度がWebview)。

Webviewの実装手順

react-native-webviewというライブラリを使います(v11.6.2を利用)。

https://github.com/react-native-webview/react-native-webview

Languagesを見るとObjective-Cなどが含まれているものの、Expoにも対応しています。

<Webview>コンポーネントに渡せるPropsは非常に多くありますが、以下のリファレンスにまとまっているので一通り目を通すといいです。

https://github.com/react-native-webview/react-native-webview/blob/master/docs/Reference.md

リファレンスの中から、マナリンクのアプリでは共通でいくつかのプロパティを設定したい内容があったので、共通コンポーネントを作りました。

import WebView, { WebViewProps } from 'react-native-webview';
import { useIdToken } from '~/hooks/auth/useIdToken';
import React from 'react';
import { getEnv, getWebHost } from '~/lib/env';

type Props = {
  uri: string;
};

export const WebFrontWebview = ({ uri, ...props }: Props & WebViewProps) => {
  const { token } = useIdToken();
  if (!token) {
    return null;
  }

  return (
    <WebView
      pullToRefreshEnabled
      allowsBackForwardNavigationGestures
      decelerationRate={1.2}
      source={{
        uri: `${getWebHost()}${uri}`,
        headers: {
          'X-XXXXXXXXXXX': token,
        },
      }}
      {...props}
    />
  );
};

まず、現時点ではマナリンクのアプリは認証済みで使う前提なので、アプリ上でFirebaseのId Tokenを取得します。
decelerationRateは画面内でのスクロール速度です。デフォルトでは異常に遅く感じたので早めにしました。

sourceプロパティのheadersでFirebaseのTokenを付与します。ここのヘッダ名はなんでもいいので独自ヘッダにしましたが、今思うとAuthorizationヘッダのほうが役割に照らして妥当だったかもしれません。

Webviewで表示するページのほうはNuxtで実装しており、SSR時にasyncData関数内でreq.headers経由でトークンを取得し、ユーザーIDを求めて検証しています。

Webviewの適用

同じ画面内にネイティブのボタンとWebviewの画面を重ねて配置できる

よく見るWebviewのアプリケーションは画面全体がWebviewになっているので、てっきりそういう技術的制約が存在するのかと思っていたら、どうやらそうでもないようです。

Webviewは単純なコンポーネントとして提供されているので、実装時にはWebviewの画面のうえにネイティブのボタンを重ねて配置することができます。

たとえば以下の例は、画面内ほぼ一杯にWebviewのエリアが広がっているものの、グローバルフッターとしてアクションボタンが配置されている例です。

  return (
    <>
      <SafeAreaView style={styles.container}>
        <ScrollView contentContainerStyle={styles.root}>
          <View flex={1} width="100%">
            <WebFrontWebview
              uri={`/teacher/${course.teacher.user_id}/courses/${course.id}/webview`}
            />
          </View>
          <ActionFooter></ActionFooter>
        </ScrollView>
      </SafeAreaView>
    </>
  );

Webview内にアクションボタンを実装してもいいのですが、アクションボタンを押したときにアプリ内の画面に遷移するといったときに、後述するイベントのやり取りが必要になりちょっと実装がややこしくなるので、個人的にはデザインや開発体制が許す範囲でネイティブでボタンを重ねて配置してもいいのではないかと思いました。

Webview画面内で特定のイベントが起こったことをアプリ側でハンドリング

iframeにおけるpostMessageのような感覚で、Webview内でエラーが起こったりユーザーがアクションしたときにアプリ側でハンドリングして画面遷移などの特定のアクションをさせることができます。

export const SubmitForm = (props: Props) => {
  const [loading, setLoading] = useState(true);
  const { handleSubmitPostMessage } = useHandleSubmitPostMessage({
    handleCompleted: () => {
      props.navigation.navigate('SubmitCompletedScreen');
    },
  });

  return (
    <View style={styles.container}>
      {loading && <LoadingIndicator />}
      <WebFrontWebview
        style={styles.webview}
        onLoadEnd={() => setLoading(false)}
        onMessage={handleSubmitPostMessage}
        uri={`/hogehoge/Submit`}
      />
    </View>
  );
};

useHandleSubmitPostMessageは独自のフックで、おおむね以下のように実装しました。

export const useHandleSubmitPostMessage = ({ loadErrorMessage, handleCompleted }: Props) => {
  const navigation = useNavigation<StackNavigationProp<Routes>>();
  const { logSystemMessage } = useMonitoring();

  const handleSubmitPostMessage = useCallback(
    (event: WebViewMessageEvent) => {
      const data: WebViewSubmitPostMessageProps = JSON.parse(event.nativeEvent.data);

      if (data.status === 'submitCompleted') {
        navigation.goBack();
        handleCompleted();
      }
      if (data.status === 'loadError') {
        navigation.goBack();
        alert(loadErrorMessage);
        logSystemMessage({
          message: `エラー発生:${loadErrorMessage}`,
          severity: 'ERROR',
        });
      }
      if (data.status === 'authError') {
        navigation.goBack();
        alert('認証状態が切れました。お手数ですが再度操作をお願いいたします');
        logSystemMessage({
          message: '認証が切れた状態でWebview画面を開きました',
          severity: 'ERROR',
        });
      }
    },
    [handleCompleted, loadErrorMessage, logSystemMessage, navigation],
  );

  return {
    handleSubmitPostMessage,
  };
};

WebViewMessageEventという型はreact-native-webviewから提供されており、event.nativeEvent.dataをJSON.parseすることでWeb側から渡ってきたイベントデータを取得し、その内容に応じて処理ができます。

ここは本質的には型安全にはできないので、可能な限りTypeを当てておきます。

Web側の実装は割愛しますが、同じようにReact Native側に同じ型のデータをエラー内容やイベントに応じてPostするように実装すればOKです。

まとめ

ハードルが高そうに思ったWebviewですが、ライブラリのおかげで意外と簡単に実装できました。できる限り避けたいですが、Web側とのデータ通信もできるので、いろいろな要件に応えられそうです。

マナリンク Tech Blog

Discussion