アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

iOS アプリの UIWebView 連携

January 10, 2014開発iOS, UIWebView

iOS アプリ開発の勉強会を催すことになった。お題は UIWebView 上のページとアプリ連携。仕事上でも使う機会の多い機能だから勉強会むけのサンプル作成ついでに利用方法などをまとめてみる。

はじめに

iOS では Web ページやアプリ内に組み込んだローカル HTML の表示用に UIWebView という Safari 相当の Web ブラウザ コントロールが提供されている。たとえば既存の Web サービスをアプリ内でホストするとか HTML として作成したサポート ページやオンライン ヘルプの表示に利用されたりする。

ページを表示するだけでも十分に便利だが UIWebView にはアプリ側のネイティブ実装と連携する機能も備わっている。連携機能は主に以下の 2 点。

  • UIWebView 上に読み込まれた JavaScript の関数をアプリ側から実行する
  • UIWebView 上に読み込まれた JavaScript からアプリ側にデータを通知する

連携によってアプリと Web サービスの機能を相互補完したりメイン開発を HTML/CSS/JavaScript でおこなって必要な時だけネイティブ実装といったことも可能になる。後者についてはうまく設計することで Android と開発リソースの大半を共有できたりもする。

以降にこの UIWebView 連携の実装手順や考察をまとめてゆく。

ローカル HTML

UIWebView 連携のサンプルとしては対象となるページ構成が Xcode プロジェクト内で完結していたほうが分かりやすいだろう。また本番アプリで Web サービスと連携するとしても、テストやデバッグでローカル HTML が役立つことも多い。

というわけで、ここではローカル HTML を利用する方法について解説する。

Xcode プロジェクトから参照する

UIWebView にローカル HTML を読み込む場合、それらは複数のファイルと階層化されたフォルダで構成されるかもしれない。たとえばページの HTML をルートに配置して JavaScript や CSS はサブフォルダから参照する、など。

その場合はアプリのインストール先でもフォルダ構成を維持しなければならない。そのためにはファイル組み込みではなくフォルダ参照を利用する。手順は以下。

  1. Xcode 上で Project Navigator を表示
  2. ページを取り込みたいグループを選択してコンテキスト メニューを表示
  3. メニューから Add Files to "PROJECTNAME"... を選択
  4. 参照するファイルとフォルダを選択するためのダイアログが表示されるので対象フォルダを選択
  5. ダイアログ上の Folders 欄で Create folder references for any added folders をチェックする
  6. ダイアログ上の Add ボタンを押して終了

文章だけだと分かりにくいかもしれないのでダイアログのスクリーン ショットを貼っておく。

ローカル HTML の参照]

参照が追加されると Project Navigator 上に対象となるフォルダが増える。ファイルやサブフォルダが存在する場合は構成がそのままツリー化される。Finder 上で対象フォルダ内の構成を変更すると Xcode がそれを検知して Project Navigator 側へ自動反映してくれるようだ。地味に便利。

ナビゲーター上の表示]

この方法には注意すべき点もある。フォルダ構成そのままなので意図せぬファイルやフォルダも組み込まれる危険性を考慮しなければならない。

たとえば隠しファイル。自動生成された .DS_Store を巻き込んだり Git 関連ファイルが巻き込まれてしまう。これらによりアプリのサイズ増大やセキュリティ的な問題 (開発環境のパス情報などが漏れる) を引き起こす可能性がある。

参照するファイル種別によるフィルタ機能がないか調べてみたが今のところ見つけられていない。よってこの機能を利用する場合はビルド前に不要ファイルを自前で除去しておくことを推奨する。

ローカル HTML を UIWebView に読み込む

プロジェクトから参照しているローカル HTML のフォルダが html で、その直下にある basic.html を読み込む処理は以下。

/**
 * ローカル HTML を読み込みます。
 */
- (void)loadHTML
{
    NSString*     path    = [[NSBundle mainBundle] pathForResource:@"basic" ofType:@"html" inDirectory:@"html"];
    NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL fileURLWithPath:path]];

    [self.webView loadRequest:request];
}

まずはローカル HTML ファイルのフルパスを取得。フォルダとして参照するためファイル名と種別だけでなくディレクトリ名も必要となる。

次にフルパスから NSURL を得る。コンビニエンス メソッドを利用する場合は fileURLWithPath を選ぶ。このパスは URL ではないので URLWithString にすると不正な指定となりページ読み込み時に WebKitErrorDomain error 101 というエラーが発生する。

NSURL をもとに NSURLRequest を生成して UIWebView - loadRequest へ指定することでページ読み込みが開始される。

事前に UIWebView デリゲートを設定していれば読み込み状況やエラーなどをハンドリングできる。連携を実装するならページ読み込みが完了するまで連携用のコントロールを無効化するなどの処理を実行しておくと、中途半端な状態で連携してしまう問題を避けられるだろう。

アプリから UIWebView 上の JavaScript 関数を実行する

アプリから UIWebView の JavaScript 関数を実行してみる。まずは HTML に以下の div を定義。アプリから指定されたテキストを受け取り、この <div> 内に表示したい。

<div class="from-app-text"></div>

次に HTML から読み込まれた JavaScript 上で以下の関数を定義。これを実行すると引数に指定されたテキストがさきほどの <div> へ表示される。

window.webViewCallback = function( text ) {
    $( ".from-app-text" ).text( text );
};

アプリ側からこの関数を実行する処理。

/**
 * UIWebView 上に読み込まれた JavaScript の関数を実行します。
 *
 * @param param JavaScript の関数へ指定するパラメータ。
 */
- (void)executeJavaScriptFunction:(NSString *)param
{
    NSString* script = [NSString stringWithFormat:@"window.webViewCallback('%@');", param];
    [self.webView stringByEvaluatingJavaScriptFromString:script];
}

UIWebView - stringByEvaluatingJavaScriptFromString は文字列として指定された JavaScript をそのまま実行してくれる。パラメータを渡したい場合は上記例のように文字列操作で指定すればよい。

関数以外の処理も実行可能だがメンテナンス性を考慮するとアプリ側の操作はシンプルなほうがよい。最小限の関数のみで操作して複雑な処理は JavaScript 側へ委ねるような設計にしておくと管理しやすいのではなかろうか。

UIWebView 上の JavaScript からアプリへコールバックする

今度は逆に UIWebView 上の JavaScript からアプリ側へコールバックしてみる。

Android の WebView というコントロールではアプリ側で定義したクラスを JavaScript のオブジェクトとして公開する機能が提供されており、これを介して相互連携できる。しかし iOS にそのような機構はない。ではどうするのかというとページ遷移を利用する。いまいち想像しにくいと思うので実際の処理を見てみよう。

まず JavaScript 側に以下の処理を定義。

location.href = "app-callback://test";

location.href にリクエストを指定するとその内容にしたがって Web ブラウザーはページ遷移する。今回はコールバックが目的なのでリクエストとなる URL のスキーム部分に独自の文字列を指定しておく。

スキームは通常 RFC 1738 - Uniform Resource Locators (URL) にて予約されたもの、たとえば httpftp などを指定する。今回はそれらと区別するために独自の app-callback という文字列を使用。

これはアプリが識別できればよいから好みで決めて構わない。ユニークさを保証するためにアプリ名を入れるのもよいだろう。とはいえ RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax の 3.1 Scheme に定義された範囲の文字を利用するのが無難。

アプリ側は UIWebViewDelegate - shouldStartLoadWithRequest を以下のように実装。

/**
 * UIWebView 上でリクエストによるページ遷移が開始された時に発生します。
 *
 * @param webView        対象となる UIWebView。
 * @param request        リクエスト。
 * @param navigationType ページ遷移の起点となった操作種別。
 *
 * @return ページ遷移を継続する場合は YES。キャンセルするなら NO。
 */
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    static NSString* const callbackProtocol = @"app-callback://";

    // アプリへのコールバックなら、その内容を表示
    NSString* url = [[request URL] absoluteString];
    if( [url hasPrefix:callbackProtocol] )
    {
        UIAlertView* alert = [[UIAlertView alloc] initWithTitle:@"From UIWebView"
                                                        message:[url substringFromIndex:[callbackProtocol length]]
                                                       delegate:nil
                                              cancelButtonTitle:nil
                                              otherButtonTitles:@"OK", nil];
        [alert show];

        return NO;
    }

    return YES;
}

このメソッドは UIWebView 上でページ遷移がおこなわれる時に発生する。つまり JavaScript で loation.href を代入したこともハンドリング可能。引数のリクエスト情報から URL を取得・解析してプロトコルがコールバック用なら専用の処理を実行。今回は URL のプロトコル以降に指定された文字列を UIAlertView で表示する。

コールバック処理ではメソッドの戻り値に NO を返してページ遷移がおこなわれないようにすること。遷移先としては無効な URL になるだろうから YES を返すと UIWebView でエラーになる可能性が高い。ここまでの処理を利用して簡単なサンプルを実装してみた。

基本的な連携

画面上部の UITextFiled に文字を入力してキーボードを Done で閉じると JavaScript が実行されて HTML 側のテキストが更新される。HTML 上の青い領域をタップするとアプリ側へのコールバックが実行され JavaScript 側で定義されたテキストを UIAlertView で表示。

Google Maps API と連携してみる

応用編として Google Maps API との連携を実装してみよう。といってもサービスを直接利用するのではなく、ローカル HTML 上にホストしてその JavaScript とアプリを連携させる。

以下の HTML を定義。

<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Google Maps API</title>
    <link href="css/style.css" media="all" rel="stylesheet" type="text/css" />
    <script src="http://maps.google.com/maps/api/js?sensor=false"></script>
    <script src="js/jquery-2.0.3.min.js"></script>
    <script src="js/map.js"></script>
</head>
<body>
    <div class="map_canvas"></div>
</body>
</html>

次にマップ読み込みとアプリ連携用の JavaScript を実装する。

( function( $ ) {
// Check define
if( !$ ) { return; }

$( document ).ready( function() {
    var latlang  = new google.maps.LatLng( 35.6938401, 139.70354940000004 );
    var options  = { zoom: 16, center: latlang, mapTypeId: google.maps.MapTypeId.ROADMAP, scaleControl: true };
    var map      = new google.maps.Map( $( ".map_canvas" )[ 0 ], options );
    var geocoder = new google.maps.Geocoder();

    // To application
    function applicationCallback() {
        var centerLatLng = map.getCenter();
        geocoder.geocode( { latLng: centerLatLng }, function( results, status ) {
                if( status == google.maps.GeocoderStatus.OK && results[ 0 ].geometry ) {
                    var url = "app-callback://map?";
                    url += "address=" + encodeURIComponent( results[ 0 ].formatted_address );
                    url += "&lat="    + centerLatLng.lat();
                    url += "&lng="    + centerLatLng.lng();
                    location.href = url;

                } else {
                    // do nothing...
                }
        } );
    }

    // Initial callback
    applicationCallback();

    // From application
    window.webViewCallbackSearchAddress = function( address, region ) {
        geocoder.geocode( { address: address, region: region }, function( results, status ) {
                if( status == google.maps.GeocoderStatus.OK ) {
                    map.setCenter( results[ 0 ].geometry.location );
                    applicationCallback();

                } else {
                    alert( "Address not found." );
                }
            }
        );
    };

    // Drag moved
    google.maps.event.addListener( map, "dragend", function() {
        applicationCallback();
    } );

} );

} )( jQuery );

applicationCallback はその名のとおりアプリ側へのコールバックを実行する。関数が呼び出された時点のマップ中央座標を取得して対応する住所と共にコールバックする。いわゆる逆ジオ コーディングである。

前述のようにアプリへのコールバックはページ遷移を利用するため location.href へ代入するものは正当な URL にしておきたい。また逆ジオ コーディングによって得られた住所には URL の構成要素に使用できない文字の入る可能性があるため encodeURIComponent によってエンコードしている。

window.webViewCallbackSearchAddress はアプリから呼び出されるグローバル関数となる。指定された住所のジオ コーディングで得られた座標へマップを移動させる。

マップを手動で移動したときにもアプリ側へ住所を返したいので dragend イベント時にコールバックを実行している。マップの中央位置が更新されたこともハンドリング可能だがこれは移動している間になんども発生するため、排他しないとコールバックが膨大になり実にうっとおしい。よってドラッグ操作 (スマホのタッチパネルならスワイプというほうが適切か?) の終了を契機とした。

実際に UIWebView 連携するアプリではコールバックに複数のパラメータを指定したくなるだろう。なのでサンプルもそうした。パラメータの書式はクエリ文字列を採用。? で開始され key=value& で区切られるおなじみのもの。これをアプリ側で解析する場合は以下のような処理になるだろう。

/**
 * URL におけるパラメータ部分を解析して取得します。
 *
 * @param url URL。
 *
 * @return 解析結果。
 */
- (NSDictionary *)parseUrlParameters:(NSString *)url
{
    NSRange range = [url rangeOfString:@"?"];
    if( range.location == NSNotFound ) { [NSDictionary dictionary]; }

    NSMutableDictionary* result = [NSMutableDictionary dictionary];
    NSArray*             params = [[url substringFromIndex:range.location + 1] componentsSeparatedByString:@"&"];

    for( NSString* param in params )
    {
        NSArray* keyValuePair = [param componentsSeparatedByString:@"="];
        if( [keyValuePair count] >= 2 )
        {
            NSString* value = [keyValuePair[ 1 ] stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
            [result setObject:value forKey:keyValuePair[ 0 ]];
        }
    }

    return result;
}

各パラメータを解析しつつ値がエンコードされた可能性を考慮してデコードしている。汎用な処理なので解析対象が複数あるならユーティリティ クラスに定義して共有するとよいかも。

アプリ機能としては UITextFiled に入力されたテキストを指定して window.webViewCallbackSearchAddress を実行、コールバックされた住所を UILabel へ表示するようにした。以下はそのスクリーン ショット。

Google Maps API 連携

サンプル プログラム

今回の内容を実装したサンプル プログラムのプロジェクト一式を GitHub にて公開。開発は Xcode 5.0.2、動作確認には iPhone 5s (iOS 7) と iPhone シミュレーターを使用。ライセンスは The MIT License (MIT)。fork、改変などはご自由にどうぞ。