目次

  1. はじめに
  2. JavaScript Callback の課題
  3. TypeSpec とは
  4. プロジェクト構成
  5. 実装方法
  6. まとめ

この記事で学べること

  • WebView とネイティブアプリ間の JavaScript Callback の仕組みと実際の課題
  • TypeSpec と OpenAPI 定義を活用した型定義の共通化手法
  • Flutter と React 間での型安全なデータ連携の具体的な実装例
  • 自動コード生成による開発効率向上とメンテナンス性改善の実践方法

想定読者

  • モバイルアプリ開発者
  • WebView を使用したハイブリッドアプリ開発者
  • 型安全性を重視するフロントエンド開発者

はじめに

株式会社 WinTicket でエンジニアをしている長田卓馬(@ostk0069)です。

現在、WINTICKET ではスマホアプリとブラウザ版の両方を提供しています。アプリは Flutter で開発され、ブラウザ版は React で構築されています。アプリ内では一部機能を WebView を用いて提供しており、今回はこの WebView 開発における課題とその解決策について詳しく解説していきます。

WebView で機能を提供する際、WebView 上での操作が完了した後、アプリ側に処理を戻す必要があります。iOS では WKWebView の evaluateJavaScript メソッド、Android では WebView の addJavascriptInterface メソッドを使用することで、JavaScript とネイティブコード間の通信が可能になります。この仕組みは一般的に「JavaScript Bridge」や「JavaScript Interface」と呼ばれることもありますが、本記事では「JavaScript Callback」と呼ぶこととします。

また、WINTICKET では、WebView のパッケージには flutter_inappwebview を採用しています。JavaScript Callback の各プラットフォームでの呼び出し方法や受け取り方については、公式ドキュメント を参照してください。

JavaScript Callback の課題

WINTICKET では以下のようなケースで JavaScript Callback を利用しています。

  • WebView を利用したポイントチャージ、口座連携、完了時にアプリに戻す処理
  • WebView でアプリでも提供している画面に遷移した時にアプリの画面に置き換える処理
  • 分析やモニタリング目的のログ送信

モバイルアプリ開発において JavaScript Callback は、ネイティブ機能と Web 技術を組み合わせる強力な手段です。WINTICKET でも多くの機能を WebView を通じて提供していますが、開発を進める中でいくつかの課題に直面しました。具体的には以下のような問題がありました:

  • Web 側とアプリ側で変数定義の形式が異なり、データの不整合が発生していた(ユーザーへの影響はなかったものの、開発効率の低下を招いていた)
  • 機能拡張に伴い Callback の数が 40 件を超え、管理が複雑化していた

これらの課題を解決するため、私たちは TypeSpec という型定義ツールを導入しました。TypeSpec によって生成した OpenAPI 定義を活用することで、Flutter と React 間で共通の型定義を維持し、両環境で型安全なコードを生成できるようになりました。これにより、開発初期段階でのエラー検出が可能になり、コードの品質と開発効率が大幅に向上しました。本記事ではこの取り組みの詳細について説明していきます。

TypeSpec とは

TypeSpec は、Microsoft 社が開発した API 定義言語で、型安全な API 仕様を記述するためのツールです。従来の OpenAPI や Swagger と比較して、より宣言的かつ簡潔な構文で複雑な API 定義を表現できます。特に以下の特徴があります:

  • 宣言的な構文: TypeScript に似た直感的な構文で、型定義や API エンドポイントを記述できます
  • モジュール性: 大規模な API 定義を複数のファイルに分割して管理できます
  • バージョン管理: API のバージョン管理を容易にする機能が組み込まれています

導入のメリット

TypeSpec を導入することで、以下のようなメリットが得られます:

  1. 型安全性の向上: 開発初期段階での型エラー検出により、実行時エラーを減少させます
  2. 開発効率の向上: 自動コード生成により、ボイラープレートコードの記述が不要になります
  3. 一貫性の確保: Web 側とアプリ側で同一の型定義を使用することで、データの不整合を防ぎます
  4. ドキュメント自動生成: API 仕様からドキュメントを自動生成できるため、ドキュメントの鮮度が保たれます
  5. 変更管理の容易さ: 型定義の変更が全ての関連コードに自動的に反映されるため、変更管理が容易になります

プロジェクト構成

私たちのプロジェクトでは、TypeSpec から各言語の定義を生成するにあたって、Web 側とアプリ側で以下のような構成を採用しています。

JavaScriptCallback生成までのフロー図

Web(React)側の構成

  • TypeSpec を使用して OpenAPI 定義を生成
  • 生成した OpenAPI 定義から swagger-typescript-api を利用して TypeScript の型定義を生成
  • 追加で callback を発火させる関数を ts-morph を利用して生成することで呼び出し時の開発体験を向上
  • これらの型定義をプライベート npm パッケージとして公開し、Web 側から利用

アプリ(Flutter)側の構成

  • Web 側と同様に TypeSpec から OpenAPI 定義を生成
  • 生成した OpenAPI 定義から swagger_parser を使用して Dart の型定義を生成
  • Dart では、WebView のコールバックを JSON で受け取るために json_serializable を用いた JSON 処理とコード生成が必要となるため、アプリのコードベース内に別パッケージとして配置
  • build_runner を使用してアノテーションから JSON のシリアライズ処理を行うコード生成を実行

この構成により、Web 側とアプリ側で一貫した型定義を維持しながら、それぞれの言語やフレームワークに最適化された形で利用できるようになります。

実装方法

全工程を記述すると非常に長くなってしまうので、重要な部分に焦点を当てて説明します。 WebView でレース詳細ページを開いた際に、アプリ側のレース詳細ページに置き換える callback を例として取り上げます。

1. 型定義ファイルの作成

TypeSpec は tsp という拡張子のファイルをサポートしており、これを使って定義ファイルを記述します。 今回のケースでは、レース詳細を開く際の callback を以下のように定義します:

@doc("showRacePage, レース詳細に遷移するためのJSコールバック")
model ShowRacePageCallback {
    @doc("レースSlug")
    slug: string;

    @doc("場ID")
    venueId: string;

    @doc("カップID")
    cupId: string;

    @doc("レースの日数")
    index: int64;

    @doc("レース番号")
    number: int64;
}

2. コード生成の設定

型定義を設定した後、tspconfig.yaml ファイルで何を生成するかを設定します。今回は OpenAPI 定義を生成したいため、@typespec/openapi3 エミッターを利用します。

emit:
  - "@typespec/openapi3"

以下のコマンドを実行することで生成可能です。

$ tsp compile .

こちらが実際に生成された openapi.yaml になります。API 定義で利用されることが多い OpenAPI ですが、型定義のみの場合は以下のような形になります。

openapi: 3.0.0
info:
  title: js-callback-schema
  version: 0.0.0
tags: []
paths: {}
components:
  schemas:
    ShowRacePageCallback:
      type: object
      required:
        - slug
        - venueId
        - cupId
        - index
        - number
      properties:
        slug:
          type: string
          description: レースSlug
        venueId:
          type: string
          description: 場ID
        cupId:
          type: string
          description: カップID
        index:
          type: integer
          format: int64
          description: レースの日数
        number:
          type: integer
          format: int64
          description: レース番号
      description: 'showRacePage, レース詳細に遷移するためのJSコールバック'

補足情報として、この方法により生成された openapi.yaml を使って Swagger ドキュメントを作成することも可能です。Swagger を活用することで、開発者はいつでも簡単に callback の定義を参照でき、視覚的に仕様を確認できるというメリットがあります。実際に私たちのプロジェクトではこれを導入しており、フロントエンドとモバイルアプリの開発者間のコミュニケーションが大幅に改善されました。また、新しいコールバックを追加する際にも、仕様の共有がスムーズに行えるようになりました。

Swaggerのサンプル画像

またこれらを TypeScript と Dart で生成した結果は以下のようになります。

TypeScript

/** showRacePage, レース詳細に遷移するためのJSコールバック */
export interface ShowRacePageCallback {
  /** レースSlug */
  slug: string;
  /** 場ID */
  venueId: string;
  /** カップID */
  cupId: string;
  /**
   * レースの日数
   * @format int64
   */
  index: number;
  /**
   * レース番号
   * @format int64
   */
  number: number;
}

ts-morph によって生成された呼び出し関数は以下のようになります。
(具体的な生成コードの詳細については割愛します。)

/** showAutoraceRaceCardのcallbackを送信する */
/** @param {ShowRacePageCallback} payload */
export async function postShowRacePageCallback(payload: ShowRacePageCallback): Promise<void> {
    window.flutter_inappwebview.callHandler("showRacePageCallback", payload);
}

Dart

// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, unused_import

import "package:json_annotation/json_annotation.dart";

part "show_race_page_callback.g.dart";

/// CallbackName: showRacePage, レース詳細に遷移するためのJSコールバック
@JsonSerializable()
class ShowRacePageCallback {
  const ShowRacePageCallback({
    required this.slug,
    required this.venueId,
    required this.cupId,
    required this.index,
    required this.number,
  });

  factory ShowRacePageCallback.fromJson(Map<String, Object?> json) => _$ShowRacePageCallbackFromJson(json);

  /// レースSlug
  final String slug;

  /// 場ID
  final String venueId;

  /// カップID
  final String cupId;

  /// レースの日数
  final int index;

  /// レース番号
  final int number;

  Map<String, Object?> toJson() => _$ShowRacePageCallbackToJson(this);
}

4. アプリ側の実装例(これまでの実装との比較)

Web(React 側の実装例)

Before

type ShowRacePagePayload {
  slug: string;
  venueId: string;
  cupId: string;
  index: number;
  number: number;
}

...

postWebViewMessageIfNeeded("ShowRacePageCallback", {
    slug,
    venueId,
    cupId,
    index,
    number,
});

After

import { postShowRacePageCallback } from "@winticket/js-callback-schema";

postShowRacePageCallback({
  venueId,
  slug,
  cupId,
  index,
  number,
});

アプリ(Flutter 側の実装)

Before

class ShowRacePageHandler extends WebViewJavaScriptHandler {
  const ShowRacePageHandler();

  @override
  Future<void> callback(
    WebViewJavaScriptHandlerContext context,
    Object argument,
  ) async {
    try {
      if (argument is! Map<String, dynamic>) {
        throw ArgumentError.value(argument);
      }

      final venueId = argument["venueId"] as String?;
      final slug = argument["slug"] as String?;
      final cupId = argument["cupId"] as String?;
      final index = argument["index"] as int?;
      final number = argument["number"] as int?;

      if (paymentMethod == null || slug == null || cupId == null || index == null || number == null) {
        // throw error
      }

      context.router.popForced();
      await context.router.push(
        RaceDetailRoute(
          venueId: venueId,
          slug: slug,
          cupId: cupId,
          index: index,
          number: number,
        ),
      );
    } catch (e, s) {
      // throw error
    }
  }
}

After

import "package:js_callback_schema/models/show_race_page_callback.dart";

class ShowRacePageHandler extends WebViewJavaScriptHandler {
  const ShowRacePageHandler();

  @override
  void callback(
    WebViewJavaScriptHandlerContext context,
    Object argument,
  ) async {
    final callback = ShowRacePageCallback.fromJson(
      argument as Map<String, dynamic>,
    );
    await context.webViewModalController.close();
    await context.router.push(
      RaceDetailRoute(
        venueId: callback.venueId,
        slug: callback.slug,
        cupId: callback.cupId,
        index: callback.index,
        number: callback.number,
      ),
    );
  }
}

導入後は実装に必要な記述量が大幅に削減され、型安全性が向上していることが見て取れます。また、JSON から Dart オブジェクトへの変換処理が自動生成されるため、手動でのパース処理が不要になり、コードの可読性も向上しています。

まとめ

本記事では、TypeSpec と OpenAPI 定義を活用した JavaScript Callback の型定義共通化について解説しました。Web と Flutter 間で一貫した型定義を維持することで、以下のような効果が得られました:

  • Web 側とアプリ側での型定義の一元管理による開発効率の向上
  • 型安全な通信によるランタイムエラーの減少
  • コード生成による実装工数の削減とメンテナンス性の改善
  • 仕様変更時の整合性維持が容易になり、保守性が向上

この型定義共通化のアプローチは、JavaScript Callback だけでなく、アプリ内のログ送信やイベント追跡などの他のユースケースにも応用可能です。特にユーザー行動分析やエラー監視において、Web 側とアプリ側で一貫したログ形式を維持することが重要となります。今後も自動生成や型定義の共通化を積極的に活用し、開発効率の向上とともに、保守性と拡張性の高いコードベースの構築を目指していきます。

参考リンク

アバター画像
2021年新卒入社。エンジニアをしています、長田卓馬です。現在は株式会社WinTicketに所属し、競輪・オートレースの投票サービスを開発しています。