書き直しゼロ!Next.jsをCapacitorでiOS/Androidアプリにした話

Next.jsで作ったWebアプリを、そのままiOS・Androidのネイティブアプリにしてみた。React NativeやExpoを使って書き直す方法もあるけど、今回はCapacitorという選択肢を選んだ。その理由と、実際にやってみてハマったところをまとめておく。

アプリの概要

対象は「Harmo Lens」というWebアプリで、吹奏楽向けの和音解析・チューナー・メトロノームをまとめたPWA。技術スタックはNext.js 15 + TypeScript + Tailwind CSS v4。

音声まわりはWeb Audio APIをフル活用していて、以下の処理を全部JSで実装している。

  • オルガン音色の加算合成
  • YINアルゴリズムによるピッチ検出(チューナー)
  • lookaheadスケジューリングによる高精度メトロノーム(±5ms以内)

React Native + Expoにしなかった理由

最初に検討したのは「React Native + Expoで書き直す」という選択肢。ネットにもチュートリアルが多いし、EAS Buildを使えばMacなしでiOSのクラウドビルドもできる。

ただ、このアプリには致命的な問題があった。Web Audio APIがReact Nativeには存在しない。

チューナーのYINアルゴリズム、メトロノームのlookaheadスケジューリング、音色合成。これら全部がWeb Audio APIに依存している。React Nativeに移植するにはreact-native-audio-api(Software Mansion製)という互換ライブラリを使うことになるが、2026年時点ではまだexperimentalで、±5msの精度が本当に出るかも怪しかった。

音楽アプリにとってタイミング精度は命なので、ここでリスクを取りたくなかった。

Capacitorを選んだ理由

Capacitorは既存のWebアプリをWebViewで包んでネイティブアプリ化するツール。Ionic社が作っている。

構造はシンプルで、next buildで生成した静的ファイルをCapacitorがアプリバイナリに同梱する。WebViewの中でアプリが動くので、Web Audio APIもそのまま動く。

ビルドフロー
Next.js(output: 'export')
  ↓
out/(静的ファイル)
  ↓ npx cap sync
ios/  android/(ネイティブプロジェクト)

選んだ主な理由はこれだけ。

  • 音声エンジンをそのまま使える(Web Audio APIが動く)
  • web/iOS/Androidを同じリポジトリで管理できる
  • Vercelへのデプロイも今まで通り

実際にやった作業

静的エクスポートの設定

next.config.jsoutput: 'export'を環境変数で切り替えられるように追加。

next.config.js
...(process.env.NEXT_OUTPUT === 'export' && { output: 'export' }),

npm run build:mobileというスクリプトも追加した。Vercel側のビルドは従来通りnpm run buildのまま変わらない。

Capacitorの導入

shell
npm install @capacitor/core @capacitor/cli @capacitor/ios @capacitor/android
npx cap add ios
npx cap add android

これでios/android/ディレクトリが生成される。

マイク権限の設定

iOSはInfo.plistに説明文を追加するだけでよかった。AndroidはAndroidManifest.xmlRECORD_AUDIOを追記。

ただしAndroidはこれだけでは不十分で、WebViewがgetUserMedia()を呼んだときにネイティブ側で権限をブリッジする処理が必要だった(後述)。

ネイティブ環境でのPWA機能の無効化

Service Worker、インストール促進バナー、サイレントモード通知。これらはWebで動いているときだけ必要で、ネイティブアプリで出ても意味がない。

Capacitor.isNativePlatform()で判定して、ネイティブ環境では表示しないようにした。

ハマったポイント

Android Studio がWSLのディレクトリを開けない

開発環境はWindowsのWSL2上にリポジトリがある。Android Studioは\\wsl$\...のパスを開こうとするが、書き込み権限の問題でプロジェクトとして認識してくれなかった。

chmod o+wxを試したが解決せず。結局、WSL内にAndroid SDKをインストールして、./gradlew assembleDebugでAPKを直接ビルドする方向に切り替えた。Android StudioはAVD(エミュレータ)の管理だけに使う運用にした。

JDK 21が必要だった

最初にsudo apt install openjdk-17-jdkを入れてビルドしたら、Capacitor 8がJava 21を要求してビルドが通らなかった。openjdk-21-jdkを入れ直して解決。

マイク権限のブリッジが必要

WebViewからgetUserMedia()を呼んでも「Permission denied」になった。Androidの権限システムとWebViewの権限要求をつなぐ処理が必要で、MainActivity.ktonPermissionRequestのオーバーライドを追加した。

MainActivity.kt
class MainActivity : BridgeActivity() {
    private var pendingWebPermission: PermissionRequest? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        bridge.webView.setWebChromeClient(object : BridgeWebChromeClient(bridge) {
            override fun onPermissionRequest(request: PermissionRequest) {
                if (request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
                    if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.RECORD_AUDIO)
                        == PackageManager.PERMISSION_GRANTED
                    ) {
                        request.grant(request.resources)
                    } else {
                        pendingWebPermission = request
                        ActivityCompat.requestPermissions(
                            this@MainActivity,
                            arrayOf(Manifest.permission.RECORD_AUDIO),
                            RECORD_AUDIO_REQUEST
                        )
                    }
                } else {
                    super.onPermissionRequest(request)
                }
            }
        })
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == RECORD_AUDIO_REQUEST) {
            val pending = pendingWebPermission ?: return
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                pending.grant(pending.resources)
            } else {
                pending.deny()
            }
            pendingWebPermission = null
        }
    }

    companion object {
        private const val RECORD_AUDIO_REQUEST = 1001
    }
}

iOSビルドはMac必須、でもXcodeが入らない

手元に古いMacがあったのでそちらでiOSビルドを試みた。macOSはSequoia(15.7.5)。

App StoreでXcodeを検索したら「macOS 26.2以降が必要です」と言われてインストールできなかった。Appleが2025年からバージョン体系を変えて、最新XcodeはmacOS 26(新体系)向けになっていた。

対処法はdeveloper.apple.comの「More Downloads」からXcode 16.xを手動でダウンロードすること。App Storeでは入れられないが、手動なら古いmacOSでも動くバージョンが落とせる。

MacのRubyが古くてCocoaPodsが入らない

XcodeはDLできたが、今度はCocoaPodsのインストールで詰まった。macOSに最初から入っているRubyが2.6で、CocoaPodsが3.0以上を要求する。

HomebrewでRubyを入れようとしたら、今度はHomebrewが古すぎてエラー。Homebrewを再インストールしてから、Ruby → CocoaPodsの順で入れ直した。

MacのNode.jsも古かった

npm installを走らせるとunrs-resolverのpostinstallが失敗。これもbrew install nodeで新しいNode.jsを入れて解決。

古いMacを引っ張り出すと、あらゆるものが古い。一通り環境を整えるのに時間がかかった。

サイレントモードで音が出ない(iOS)

iOSで動かしてみたら、サイレントモードにすると全部無音になった。音楽アプリとしては致命的。

最初にSwiftで対処しようとした。MainViewController.swiftを作って、AVAudioSessionのカテゴリを.playbackに設定する。

MainViewController.swift
class MainViewController: CAPBridgeViewController {
    override func viewDidLoad() {
        try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
        try? AVAudioSession.sharedInstance().setActive(true)
        super.viewDidLoad()
    }
}

Info.plistUIBackgroundModes: audioも追加した。でも音は出なかった。タイミングの問題かと思ってviewDidLoadsuperの前後を変えたり、loadViewに移したりを4〜5回繰り返したが変わらず。

原因はWKWebViewの構造にあった。WebViewの中で動くJSはWKWebView自身のオーディオセッションに縛られていて、外からSwiftで設定したセッションが反映されない。

解決したのはJS側で無音の<audio>をループ再生する方法。ArrayBufferで1秒の無音WAVを生成してループ再生すると、WebKitが「メディア再生が始まった」と認識してサイレントモードを突破するセッションに切り替わる。

silentAudioUnlock.ts
export function unlockSilentMode(): void {
  if (unlocked) return;
  const audio = new Audio(buildSilentWavUrl()); // 1秒の無音WAV
  audio.loop = true;
  audio.volume = 0.001;
  audio.play().then(() => { unlocked = true; }).catch(() => {});
}

ユーザーのタップ操作のハンドラの中で呼ぶのがポイント。iOSのオートプレイ制限があるので、操作なしにplay()は動かない。

MainViewController.swiftのAVAudioSession設定はそのまま残している。単独では不十分だったが、組み合わせることでバックグラウンド時の挙動も安定する。

結果

Androidはエミュレータでも実機でも動いた。見た目はWebアプリとほぼ一緒。

チューナーのマイク機能はエミュレータでは「オーディオソースが起動できない」エラーになる(エミュレータにマイクがないため)。実機では権限ダイアログが正常に出て、マイクも使えた。

iOSも実機で動作確認できた。Xcodeの「Signing & Capabilities」でApple DeveloperのチームIDを設定して実機にインストール。サイレントモードでの音出しも確認済み。

まとめ

Web Audio APIへの依存が強いアプリだったので、正直Capacitor以外の選択肢はなかったと思う。書き直しゼロで動いたのはかなり助かった。

ただ「そのまま包む」といっても、権限まわりだけはWebとネイティブで仕組みが違うので、WebViewとネイティブの間をつなぐコードが多少必要になる。「Webの知識があればすぐ」とはならないところ。

環境構築で思ったより時間を取られたが、アプリ本体のコードをほぼ触らずにネイティブ化できたので驚き(WebViewだけどど)。iOSでのアプリリリースはAppleに課金しないといけないので一旦、自分のiPhoneだけで動作確認したうえで、Webアプリのまま運用しようと思う。