JINSテックブログ
🌟

iOS業務アプリ開発!WebView/スキャナ統合とIn-House配信を乗り越えた全記録

に公開

0. はじめに

この記事は、JINS Advent Calendar 2025 の 1日目 の記事です。

初めてのAdvent Calendar参加で、とてもワクワクしています!🎄✨
この機会に、自分の学びや経験を共有できることを、とても嬉しく思います。

こんにちは!JINSのITデジタル部エンジニアリング課で、主に社内システムの開発・運用を担当しています、タン(Qiitaアカウントは こちら )です。

最近、FlutterでiOSアプリ(特に社内向けの業務アプリ)を開発する機会があり、WebViewベースの管理画面にネイティブスキャン機能を統合するという、なかなか今まで入ったことない領域に挑戦しました。

以前、AsReaderとWebViewの連携という記事で基本的な通信部分について書きました。

今回はそこから一歩進んで、 アプリ全体の「設定」「デバッグ」「社内配布」 という、iOS開発のライフサイクル全体で学んだ知見を共有します。

特に、私自身にとって一番の「壁」だったのが、 「Enterprise Distribution(社内配布)」 です。正直、開発前は証明書やプロファイルの仕組みが全く理解できていませんでしたが、今回ゼロから挑戦したことで、ようやく「何を」「なぜ」やっているのかを掴むことができました。

この記事では、同じようにFlutterでiOS業務アプリ開発に挑戦する方に向けて、私が学んだことを実際のコード例とともに詳しく解説します。

1. アーキテクチャ概要(WebView/スキャナ統合)

今回実装したアプリは、既存のWeb管理画面(WebView)をベースに、ネイティブ機能(AsReader / カメラスキャン)を呼び出す構成です。

WebViewとFlutterの通信

WebViewとFlutter間の通信は JavaScriptChannel (Web \rightarrow Flutter) と runJavaScript (Flutter \rightarrow Web) で実装しました。

// main.dart
class WebViewManager {
  void initialize(String initialUrl) {
    _controller = WebViewController()
      // WebからFlutterへの通信路
      ..addJavaScriptChannel('ScanChannel',
        onMessageReceived: (message) => _handleWebViewMessage(message.message))
      ..loadRequest(Uri.parse(initialUrl));
  }
  
  // FlutterからWebへの通信(結果を返す)
  void sendMessage(Map<String, dynamic> message) {
    _controller.runJavaScript('''
      // 'flutter_message' イベントをWeb側で発火させる
      document.dispatchEvent(new CustomEvent('flutter_message', {
        detail: ${jsonEncode(message)}
      }));
    ''');
  }

  // Webからのメッセージを処理
  void _handleWebViewMessage(String messageStr) async {
    final messageJson = jsonDecode(messageStr);
    switch (messageJson['type']) {
      case 'start_scan': // AsReaderスキャン要求
        await _asReaderService.startScan(messageJson['scanId']);
        break;
      case 'request_native_camera': // カメラスキャン要求
        await _handleNativeCameraRequest();
        break;
    }
  }
}
// WebView側 (JavaScript)

// スキャン要求 (Web -> Flutter)
function requestCameraScan() {
    ScanChannel.postMessage(JSON.stringify({
        type: 'request_native_camera',
        timestamp: Date.now()
    }));
}

// 結果受信 (Flutter -> Web)
document.addEventListener('flutter_message', function(event) {
    if (event.detail.type === 'camera_scan_result') {
        document.getElementById('result').textContent = 
            'スキャン結果: ' + event.detail.result;
    }
});

Note:
WebViewとFlutter間のJavaScriptChannelPostMessageを使った双方向通信の「仕組み」についての詳細は、前回の記事(FlutterとWebviewでAsReaderを使い倒す連携テクニック)で詳しく解説しています。この記事では、このアーキテクチャを前提として話を進めます。


2. iOS権限管理(Info.plistの重要性)

Flutterプロジェクトといえども、iOSアプリとしてビルドする以上、Xcodeでの設定は避けて通れません。

Flutter (pubspec.yaml) で管理すること

pubspec.yaml は、主にDart側の設定ファイルです。

# pubspec.yaml
name: my_app
version: 1.0.0+1 # バージョンとビルド番号
  • version: ここでアプリのバージョン (1.0.0) とビルド番号 (+1) を管理します。iOSの Info.plist にも自動で反映されるため、バージョン管理は pubspec.yaml に統一するのがおすすめです。

Xcode (Info.plist) で管理すること

アプリの表示名や、OS(iOS)が管轄する「権限」に関する設定は、Xcode側で行う必要があります。

① アプリの表示名 (Bundle display name)

pubspec.yamlname はプロジェクト名です。ホーム画面に表示されるアプリ名を変えたい場合は、Info.plistBundle display name を編集します。

<key>CFBundleDisplayName</key>
<string>(表示したいアプリ名)</string>

② iOS特有の「権限設定」

ここが重要です。 iOSでは、カメラやBluetoothなど、プライバシーに関わる機能にアクセスする場合、ユーザーになぜその権限が必要かを説明する文言Info.plist に記述することが必須です。

これが無いと、権限を要求した瞬間にアプリがクラッシュします。

最初、カメラスキャン機能の実装時、permission_handler で権限を要求するDartコードだけ書いて満足していました。しかし、Info.plist の設定を忘れていたため、実機でスキャンボタンを押した瞬間にアプリが説明なくクラッシュし、原因特定に少し時間がかかりました…

今回のアプリで必須だった権限設定:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>AsReaderデバイスとの通信にBluetoothを使用します</string>

<key>NSCameraUsageDescription</key>
<string>このアプリはQRコード・バーコードのスキャンにカメラを使用します</string>

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

📝 余談:permission_handlerのisPermanentlyDeniedバグ対応

開発中に遭遇した興味深い問題として、permission_handlerパッケージのisPermanentlyDeniedが実際の権限状態と異なる場合があります。

問題の現象:

  • ユーザーがカメラ権限を「許可」しているにも関わらず
  • Permission.camera.statusisPermanentlyDenied = trueを返すケースがある
  • このため、権限があるのにカメラ機能が使えない状況が発生

解決策:実際のカメラアクセステスト

isPermanentlyDeniedの場合でも、実際にカメラハードウェアにアクセスして権限を再確認する手法を実装:

// isPermanentlyDeniedの場合、実際のカメラアクセスを試行
if (status.isPermanentlyDenied) {
  final actualAccess = await _verifyActualCameraAccess();
  if (actualAccess) {
    return true; // 実際にはカメラ権限あり
  } else {
    onError?.call('カメラ権限が拒否されています。設定から許可してください。');
    return false;
  }
}

// 実際のカメラアクセスを試行して権限を確認
Future<bool> _verifyActualCameraAccess() async {
  try {
    // 実際にMobileScannerControllerでカメラを起動してみる
    final testController = MobileScannerController();
    await testController.start().timeout(Duration(seconds: 3));
    
    // 短時間待機してカメラが実際に動作することを確認
    await Future.delayed(Duration(milliseconds: 500));
    
    // カメラを停止・破棄
    await testController.stop();
    await testController.dispose();
    
    return true; // カメラアクセス成功 = 権限あり
  } catch (e) {
    return false; // カメラアクセス失敗 = 権限なし
  }
}

この手法の利点:

  • permission_handlerの判定結果に関係なく、実際の権限状態を確認できる
  • iOSシステムレベルでの権限チェックとなるため、より正確
  • ユーザー体験の向上(権限があるのに使えない問題を回避)

注意点:

  • カメラハードウェアへの実際のアクセスが発生するため、タイムアウト処理が必要
  • リソースの適切な解放(dispose)を忘れずに実行
  • 頻繁に実行すると電池消費やパフォーマンスに影響する可能性

3. ネイティブとWebviewのデバッグ

FlutterアプリのデバッグはFlutter DevToolsが強力ですが、ネイティブ機能やWebviewが絡むと、それだけでは不十分です。

iOSネイティブ (Swift/Objective-C) 側のデバッグ

asreader_sdk4 のようなネイティブプラグインがうまく動かない場合、Xcodeのデバッガが必須です。

  1. Flutterプロジェクトの ios/Runner.xcworkspace をXcodeで開きます。
  2. VS Codeなどでアプリを実行(ビルド)した後、Xcodeの「Debug」\rightarrow「Attach to Process」で実行中のアプリ(プロセス名)を選択します。
  3. これで、Xcodeのコンソールにネイティブ側のログ (NSLogos_log) が表示されるようになります。

Webviewのデバッグ方法

webview_flutter で表示しているWebページ内のデバッグには、2つの方法があります。

①(簡単な方法)Webの console.log をFlutterの print に出す

WebViewController の初期化時に setOnConsoleMessage を設定するだけです。(※ WebViewManager のコード例に記載済み)

_controller = WebViewController()
  // ... (addJavaScriptChannelなど)
  ..setOnConsoleMessage((message) {
    // Webview側 (JS) の console.log がここに転送される
    print('[WebView] ${message.level.name}: ${message.message}');
  })
  // ... (loadRequestなど)

JavaScript側で console.log("ボタンが押されました!"); と書いておけば、Flutterのコンソールに [WebView] LOG: ボタンが押されました! と表示されます。JavaScriptChannel がうまく動かない時など、Web側の処理がどこまで動いているかを切り分けるのに非常に便利でした。

②(高機能な方法)Safari Webインスペクタ

DOMの構造やCSS、ネットワーク通信の詳細を見たい場合は、SafariのWebインスペクタを使います。

【準備:Mac (Safari) 側】

  1. Safariの「環境設定」\rightarrow「詳細」\rightarrow「メニューバーに"開発"メニューを表示」にチェック。

【準備:iOSシミュレータ / 実機 側】

  1. (実機の場合)「設定」\rightarrow「Safari」\rightarrow「詳細」\rightarrow「Webインスペクタ」をオン。

【デバッグ手順】

  1. アプリをシミュレータまたは実機で起動。
  2. MacのSafariを開き、「開発」メニュー \rightarrow [(シミュレータ名 / 実機名)] \rightarrow [(表示中のWebページのタイトル)] を選択。
  3. Webインスペクタが起動し、PCのブラウザと同じようにデバッグできます。

4. 企業内(In-House)アプリ配信

ここが今回の開発で最も時間がかかり、そして最も学びが深かった部分です。

正直に言うと、私はこれまでiOSの「証明書」「プロビジョニングプロファイル」といった署名関連の仕組みが全く理解できていませんでした。「なぜこんなに複雑なんだ」「何が何に紐づいているんだ」と混乱するばかり...

しかし、今回In-House配信(Apple Developer Enterprise Program)をゼロから実装したことで、ようやくその全貌が理解できました。

このセクションでは、過去の自分のように混乱している人に向けて、これらのキーワードが何を意味し、どのように連携して「アプリが動く」ようになるのかを、できるだけ分かりやすく解説します。

重要キーワード

キーチェーンアクセス「証明書アシスタント」

  • Macのキーチェーンアクセス → 「証明書アシスタント」→「認証局に証明書を要求」で一括作成
  • 秘密鍵・公開鍵ペアの生成とCSR作成が同時に実行される
  • 生成されるファイル:
    • 秘密鍵:Macのキーチェーンに自動保存(外部ファイルなし)
    • CSRファイルCertificateSigningRequest.certSigningRequest(デスクトップに保存)

CSR(Certificate Signing Request)

  • 上記の証明書アシスタントで自動生成される申請書ファイル(.certSigningRequest拡張子)
  • Apple Developer Portalの「Certificates」セクションでアップロードして証明書を申請

証明書(Distribution Certificate)

  • Appleが「この開発者は信頼できる」と保証する公式文書(開発元の身分証明書
  • Enterprise用は「In-House and Ad Hoc」タイプ、有効期限は通常1年間

プロビジョニングプロファイル

  • Appleが発行する「許可証」 ファイル
  • プロファイルが紐づける3つの情報
    • 【What】 アプリID (App ID)

      • 「どのアプリか」を識別します。
      • (例: com.yourcompany.my-app)
    • 【Where】 デバイスID (UDID)

      • 「どのiPhone/iPadで動かすか」を識別します。
      • (例: あなたの開発用iPhoneの固有ID)
    • 【Who】 デジタル証明書 (Certificate)

      • 「どの開発者が作ったか」を証明します。
      • Apple Developer Programに登録された信頼できる開発者であることの証明

Bundle ID

  • アプリを一意に識別するID(例:com.yourcompany.yourappname
  • 逆ドメイン形式で、世界で唯一の識別子

IPAファイル(iOS App Store Package)

  • iOSアプリのインストールファイル本体(このファイルをMDMなどに登録して配布)

実行時検証

iPhoneでアプリ起動時に以下をチェック:

  1. 署名が改ざんされていないか
  2. 証明書がAppleのものか
  3. プロビジョニングプロファイルが有効か
  4. Bundle IDが一致するか

iOSアプリ署名の認証フロー

この複雑な仕組みを図にすると、以下のようになります。

簡単に言うと:

  1. 身分証明書を作る(開発証明書)\rightarrow 「私は本物の開発者(会社)です」
  2. 許可証を作る(プロビジョニングプロファイル)\rightarrow 「このアプリ(Bundle ID)を、Enterprise(社内)で動かしていいですよ」
  3. アプリに印鑑を押す(ビルド・署名)\rightarrow 「このアプリは私が作りました」
  4. iPhoneが確認 \rightarrow 「印鑑(署名)が本物だから安全だね、起動OK!」

この流れを一度理解できれば、エラーが出たときに「証明書の期限切れだな」「プロファイルにBundle IDが合ってないな」と切り分けができるようになります。

IPAファイル作成までの流れ (Xcode)

方法1: Flutterコマンドを使用(推奨)

ExportOptions.plist という設定ファイルさえ用意すれば、Flutterコマンド一つでIPAを書き出せます。

flutter build ipa --release --export-options-plist=ios/ExportOptions.plist

ExportOptions.plistとは?
iOSアプリの配布方法(App Store, Ad Hoc, Enterpriseなど)を指定する設定ファイルです。

ExportOptions.plist (Enterprise配布用の例):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>enterprise</string>
    
    <key>teamID</key>
    <string>YOUR_ENTERPRISE_TEAM_ID</string>
    
    <key>provisioningProfiles</key>
    <dict>
        <key>com.yourcompany.asreader.scanner</key>
        <string>Your Enterprise Provisioning Profile Name</string>
    </dict>
    
    <key>compileBitcode</key>
    <false/>
</dict>
</plist>

このplistを用意しておけば、CI/CDでのビルド自動化も容易です。

方法2: Xcodeを使用(従来の方法)

  1. Xcodeで Signing & Capabilities タブを開き、「Automatically manage signing」のチェックを外します。
  2. Provisioning Profile (In-House用) と Signing Certificate (In-House用) を手動で設定します。
  3. 「Product」\rightarrow「Archive」を選択。
  4. アーカイブが完了したら、「Distribute App」\rightarrow**「Enterprise」**を選択し、IPAファイルを書き出します。

MDMでの配布

作成したIPAファイルを、MDM (Mobile Device Management) の管理画面にアップロードします。
これにより、管理者は社用デバイスに対し、アプリのインストールやアップデートをプッシュ通知で配信できます。


5. プロジェクト設定のまとめ

Flutter (pubspec.yaml)

主要な依存関係です。

dependencies:
  flutter:
    sdk: flutter

  # WebView関連
  webview_flutter: ^4.4.2
  
  # カメラスキャン関連
  mobile_scanner: ^5.2.3
  permission_handler: ^11.4.0
  
  # AsReader SDK (ローカルパッケージ)
  asreader_sdk4:
    path: asreader_sdk4/

Xcode (project.pbxproj / build.xcconfig)

Enterprise配布用に、署名設定とiOSのデプロイメントターゲット(最小サポートOS)を明示します。

// build.xcconfig
DEVELOPMENT_TEAM = YOUR_ENTERPRISE_TEAM_ID
CODE_SIGN_IDENTITY = "iPhone Distribution: Your Company Name"
PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.asreader.scanner
IPHONEOS_DEPLOYMENT_TARGET = 12.0

6. まとめ・学んだポイント

技術的な学び

  1. WebViewとFlutterの統合は思ったより簡単

    • JavaScriptChannel とJSON形式でのメッセージ交換により、型安全で双方向な通信が容易に実現できました。
  2. permission_handlerInfo.plist はセットで考える

    • Dart側で権限を要求するコードを書いたら、必ず Info.plist に説明文(UsageDescription)を追加する癖をつけること。忘れるとクラッシュします。
  3. permission_handler の状態を鵜呑みにしない

    • isPermanentlyDenied が実際の権限状態と異なるケースに遭遇しました。
    • MobileScannerController などで実際にハードウェアアクセスを試行し、権限を二重チェックするアプローチが非常に有効でした。
  4. Enterprise配布は「証明書管理」が全て

    • flutter build ipa コマンドと ExportOptions.plist の組み合わせが、手動Xcode操作よりも確実で、自動化にも繋がる最強のソリューションでした。

プロジェクト管理の学び

  1. 段階的な機能実装が効率的
    • 基本WebView \rightarrow ハードウェア連携 \rightarrow カメラ機能 の順に、各段階でテストとデバッグを繰り返すことが重要でした。
  2. 実機テストの重要性
    • 特に権限系とハードウェア(Bluetooth)系は実機でのテストが必須です。シミュレーターでは発見できない問題が多数ありました。

7. さいごに

最後まで読んでいただき、ありがとうございます!

Flutter × iOS × WebViewという組み合わせ、すごく可能性を感じました!

今回、あんまり触っていなかったアプリ開発に挑戦してみて、「アプリ開発って、Web開発と全然違う!」というのが一番の衝撃でした😲。WebならブラウザでOKなところも、アプリだと実機(Bluetoothやカメラ)で動かさないと本当の動作がわからなかったり、OSの厳格な権限設定(Info.plist)があったり...

そして何より、複雑なアプリの配布プロセス!

正直、最初は「署名って何?プロファイルって美味しいの?」状態でした(笑)

確かに難しくて何度も壁にぶつかりましたが、あれほど複雑に思えたiOSの署名とEnterprise配布のプロセスも、一つ一つ紐解いていけばちゃんと理解できるんだ!と分かった瞬間は、本当に嬉しかったです。今までやったことがない領域だったので、すごく楽しくて、めちゃくちゃ勉強になりました!

この記事が、同じような課題に取り組む誰かの助けになれば幸いです。


明日は @tw_c さんの「チームの一員に! 業務部門の協力を引き出すコミュニケーションのヒント」の記事です。お楽しみに!

それでは、素敵なクリスマスと良いお年をお迎えください!🎅🎄

JINSテックブログ
JINSテックブログ

Discussion