hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

ハードウェアと連携するiOSアプリをNuxt 3とCapacitorで作る

はじめに

テックブログ初投稿となります、野崎です(社内ではサイモンと呼ばれています)。 ウェブアプリケーションの開発を担当しております。

hacomonoではフィットネスクラブやジムの店舗管理システムと連動して使えるPOSレジを開発、提供しています。

このPOSレジはiOS、特にiPadでの動作をターゲットに開発されていますが、チームには専属のアプリエンジニアが1名しかおらず、自分含めみなウェブアプリケーションエンジニアです。 そのような条件から、iOSアプリの開発はウェブを中心に据える必要がありました。本記事では、クロスプラットフォームライブラリであるCapacitorをNuxtと組み合わせ、”ほぼウェブな”iOSアプリ開発をご紹介します。

以後、本文に登場するコードのフルセットはgitリポジトリにてご覧になれます。

最低限の実装にいくつか実装を追加しているため、全く同じ見た目ではない前提でご参考いただければ幸いです。

詳しくは後述しますが、サンプルコードのリポジトリではnpx cap sync などで誤って上書きしないよう、Capacitorによるswiftの自動生成コードは capacidor-ios とリネームしています。

技術選定と要件

技術選定の観点

Capacitor(キャパシター)はIonicベースのクロスプラットフォームフレームワークです。Vue.jsやReactとといった人気のウェブアプリケーションフレームワークと組み合わせて、簡単にネイティブアプリを構築することができます。 選定当時、いくつかの要件をあげ、検証を行った結果Capacitorがよいという判断になりました。選定要件には主に次の4つがあります:

1.ハードウェアと連携できること

POSレジは、その機能を提供するためにレジの本体であるiPadアプリだけでなくレシートプリンター、バーコードリーダー、キャッシュドロワーといった外部ハードウェアとシームレスに連携できる必要があります。 これらのハードウェアはSDKが提供されており、ソフト側から制御できるもののウェブアプリケーションとの連携においては大なり小なりの苦労が伴います。検証段階にてそのようなハードウェアと問題なく連携できるかが問われておりました。

2. チームにはウェブアプリケーションエンジニアしかいなかったこと

現在でこそアプリエンジニアの方に参画いただけているものの、POSチーム発足当時はウェブアプリケーションエンジニアしかいませんでした。そのため、アプリケーションの開発はウェブに完結したかった事情があります。

3. デザインシステムの構築を推進していること

hacomonoで提供している各プロダクト間で共通言語となるデザインシステムの構築を進めています。ウェブアプリケーションエンジニア中心のチームで今後もウェブサービスの提供が中心となるため、うかつにiOSの画面実装を増やしたくない、という狙いが当時からありました。

4. iOSアプリ内部でバックエンドapiの環境を動的に切り替えたい

この要件は社内固有の事情に依るのですが、hacomonoのウェブアプリケーションはURLを使って環境を識別しています。そのためiOSアプリ側からも「どの環境を使うか」を設定値として入力する機構が必要でした。

補足: ウェブアプリケーション部分

iOSアプリで表示するウェブアプリケーションの部分も概観しておきます。 iOSアプリからは、WebViewでウェブアプリケーションを表示することを目指しました。hacomonoではPOSレジ以外のプロダクトでもNuxtが使われており、レジ機能のコアになる機能はNuxtで構築しています。ウェブサービスの全体は、このNuxtで構築されたウェブフロントエンドから各種バックエンドapiに通信するアーキテクチャになっています。

プロジェクトにCapacitorを導入する

Capacitorは多くの人にとって馴染みのないかと思いますので、簡単にドキュメントに沿った導入方法を確認します。日本語のドキュメントも非常に充実していますので、詰まることなく作業を進められます。

前提条件

手元に構築済みのウェブアプリケーションがあり、yarnやnpmといったパッケージマネージャが使えるものとします。

既存のプロジェクトにCapacitorを導入する

公式ドキュメントの「既存のウェブアプリケーションにCapacitorを追加する」に沿って順番に作業します。

npm i @capacitor/core
npm i -D @capacitor/cli

# capacitor設定を初期化
npx cap init

# iOSのライブラリを追加
npm i @capacitor/ios

# iOSプロジェクトを作成する
npx cap add ios

npx cap add ios を終えるとルート配下に ios ディレクトリが作成され、中にはUIKitベースのプロジェクトができていることが確認できます。ここから自動生成コードを要件に合うようにカスタマイズしていきます。

自動生成コードの確認

ios ディレクトリにCapacitorが生成したコードができていると述べました。生成時点でできるコードは以下のようになっており、UIKitベースの実装であることがわかります。

  • Base.lproj
    • Main.storyboard
      • スプラッシュの次のWebViewを生成するstoryboardです
      • このstoryboardには、Capacitorのネイティブライブラリに含まれるCAPBridgeViewControllerが紐付けられています。
    • LaunchScreen.storyboard
      • デフォルトで作成されるスプラッシュです。
  • AppDelegate
    • main関数相当のDelegateです。 UIApplicationDelegate を継承。

SwiftUIベースの、できるだけ画面を持たない構成に修正していく

iOSアプリのUI実装は、SwiftUIとUIKitを組み合わせて実現します。iOSアプリ開発のトレンドはSwiftUIに向かいつつも、それだけでは不便なケースが多く、CapacitorのネイティブライブラリもUIKitにより構成されています。

要件を満たすべく、自動生成コードからstoryboardを使った画面の構成を避けてSwiftUIベースのWebViewを構築します。

Capacitorのネイティブライブラリは CAPBridgeViewController がウェブとネイティブをつなぐ役割を担っているため、カスタマイズを施してViewControllerをSwiftUI/Viewと組み合わせて使える状態を目指します。

はじめに、作業後のファイル構成例を役割ごとに示します。

  • エントリポイント
    • WebApp.swift
      • App を継承した構造体。アダプタを挟んで AppDelegate をSwiftUIで使えるようにします。
  • WebViewの構築準備
    • MainView.swift
      • WebViewControllerRepresentable を返す中間View。後述。
    • EnvironmentView.swift
      • 通信する環境のURL指定を促します。サンプルではViewとして作ってしまいましたがウェブの構築にあたって必要な入力が得られれば普通のアラートなどでも問題ありません。
      • ViewControllerにわたすインプットの受け皿であればなんでもよいクラスです。
  • View/ViewController
    • CDBridgeViewController.swift
      • Capacitorのネイティブライブラリが用意している CAPBridgeViewController を継承したViewControllerクラス。
      • hacomonoのPOSレジのように、カスタマイズするユースケースも想定されており公式ドキュメントに沿ってカスタムコードを追加します。
    • WebViewControllerRepresentable.swift
      • ViewControllerからSwiftUI/Viewを生成するための中間表現となる構造体。 UIViewControllerRepresentable に準拠することで、手動でnewしたViewControllerからViewを生成できるようにします。
  • ユーティリティ
    • AppRuntimeState.swift
      • URLなどなにがしか受け付けた入力を持っておくクラスです。 ObservableObject を継承しています。

それでは、主要なクラス・構造体のソースコードを紹介します。ここに示すサンプルコードは、ポイントが見えやすいよう必要最低限の記載にとどめております。Viewのプレビューも外しています。

WebApp はCapacitorが生成するAppDelegateをSwiftUIで使えるようにする構造体で、いわゆるmain関数の働きをします。ポイントは UIApplicationDelegateAdaptor で、UIKitのAppDelegate を生成するプロパティラッパになります。ここから、任意の前処理を行うViewなどを返します。

import SwiftUI

@main
struct WebApp : App {
    @UIApplicationDelegateAdaptor (AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            EnvironmentView().environmentObject(AppRuntimeState())
        }
    }
}

WebApp.swift

WebApp から推移してくる最初のViewです。UIViewControllerの生成、WebView表示前にやっておきたい前処理などを挟む役割を想定しており、さらに責務を小さくするならViewではなくアラート主体のControlllerに置き換えてもよいです(POSレジではそのようになっています)。

import SwiftUI
import Foundation

struct EnvironmentView: View {
    @State
    private var envString = ""
    @EnvironmentObject
    var appRuntimeState: AppRuntimeState
    
    var body: some View {
        NavigationView {
            VStack {
                Button(
                    action: {
                        self.appRuntimeState.isEnvironmentValid = true
                    },
                    label: {
                        Text("GO")
                    }
                )
                NavigationLink(
                    destination: MainView(hostUrl: self.$envString.wrappedValue),
                    isActive: $appRuntimeState.isEnvironmentValid) {
                    EmptyView()
                }
            }
        }
    }
}

EnvironmentView.swift

ここからWebViewの生成処理です。 MainViewWebViewControllerRepresentable を経由してWebViewを表示します。

import SwiftUI

struct MainView: View {
    var hostUrl: String = ""
    
    var body: some View {
        VStack {
            WebViewControllerRepresentable(hostUrl: self.hostUrl)
        }
    }
}

MainView.swift

本カスタマイズ一番のポイントは WebViewControllerRepresentable といえます。UIViewControllerRepresentable はSwiftUI内でUIKitのViewControllerを生成するためにインターフェースとなるプロトコルです。 すでに触れたように、Capacitorのネイティブライブラリは CAPBridgeViewController でビューの生成にまつわる様々な処理をラップしています。 したがって、CAPBridgeViewController を継承したViewControllerからViewを作れるようになる必要があり、WebViewControllerRepresentable はUIKit/ViewControllerとSwiftUI/Viewの変換を担うクラスとなります。

import Foundation
import SwiftUI

struct WebViewControllerRepresentable : UIViewControllerRepresentable {
    var hostUrl: String = ""
    
    func makeUIViewController(context: Context) -> some UIViewController {
        let viewController = CDBridgeViewController()
        viewController.hostUrl = self.hostUrl
        
        return viewController
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
    }
}

WebViewControllerRepresentable.swift

WebViewControllerRepresentable で生成したViewControllerの内部実装です。ここで、WebViewの生成をカスタマイズします。なお、 viewDidLoad 内に登場する変数 webViewWKWebView のインスタンスで、インスタンスの生成自体は CAPBridgeViewController にて行われています。つまり、サブクラスからみるとスーパー参照の構造になります。

import Capacitor
import UIKit
import Foundation

class CDBridgeViewController: CAPBridgeViewController {
    var hostUrl: String = ""
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.becomeFirstResponder()
        
        guard let u = URL(string: self.hostUrl) else { return; }
        webView?.load(URLRequest(url: u))
    }
}

CDBridgeViewController.swift

登場したクラスや構造体を一部端折ってまとめますと図のようになります。 以上のような実装を踏まえることでstoryboardを廃してSwiftUIとCapacitorネイティブライブラリを組み合わせて利用することができるようになりました。

おまけ: Capacitorとハードウェアの連携

冒頭で触れたように、POSレジは外部ハードウェアとケーブルによって接続して動作します。Capacitorはプラグイン用のapiを公開しており、独自のプラグインを作成することができます。ハードウェアの制御を担うソフトウェアはプラグインとして開発し、このプラグインをウェブアプリケーションから呼び出す形式を採用しています。

おわりに

iOSアプリ対応だけではなく、ハードウェアとの連携やウェブに重心を置く開発も踏まえた技術選定は外部公開されている事例も少なく、参考にできる情報が少ないのが実情です。

取り組みの一部紹介ではありますが、ご参考いただければ幸いです。