2014年12月 5日

iOS

let UIWebView as WKWebView

  • このエントリーをはてなブックマークに追加

Yahoo! JAPAN Tech Advent Calendar 2014の5日目の記事です。一覧はこちら

どうも、taketo1024 こと佐野です。現在はヤフーの新しい検索アプリ「SmartSearch」のサービスマネージャ兼 iOS アプリ開発を担当しています。

SmartSearch-img

この記事では SmartSearch のコードでも使っている、UIWebView と WKWebView の分岐処理をキレイに隠蔽(いんぺい)するためのテクニックをご紹介したいと思います。

WKWebViewとは

iOS 8 から WebKitFramework が導入され、従来の UIWebView とは別に WKWebView というクラスが使えるようになりました。両者の違いは こちらのスライド でも詳しく解説されていますが、簡単に言うと WKWebView は UIWebView より 速くて高機能 なのです。

ただ WKWebView は iOS 7 以前では使えませんし、UIWebView と共通の機能もインターフェースが微妙に違っていたりして互換するようにはできていません。

例えば同じ loadRequest でも、UIWebView は戻り値の型が void なのに対して WKWebView では WKNavigation となっています。

// UIWebViewの場合
func loadRequest(request: NSURLRequest!)

// WKWebViewの場合
func loadRequest(request: NSURLRequest!) -> WKNavigation!

WKWebView と UIWebView を共存させたい

iOS 8 以降では WKWebView を使いたいけど、iOS 7 以下のサポートはまだ切りたくない…そういう考える開発者は多いと思います。

しかし愚直に WKWebView と UIWebView を共存させようとすると、こんな感じのコードになってしまいます:

class MyViewController: UIViewController {

    // どちらか一方はnil
    var wkWebView: WKWebView?
    var uiWebView: UIWebView?

    override func viewDidLoad() {
        super.viewDidLoad()

        // iOS8以上と7以下の場合で、別々にインスタンスを作る
        if NSClassFromString("WKWebView") != nil {
            wkWebView = WKWebView(frame: )
            self.view.addSubview(wkWebView)
        } else {
            uiWebView = UIWebView(frame: )
            self.view.addSubview(uiWebView)
        }
    }

    // 戻るボタンが押された場合の処理
    @IBAction func goBackButtonTapped() {
        // wkWebView, uiWebView で分岐
        if wkWebView != nil {
            if wkWebView!.canGoBack {
                wkWebView!.goBack()
            }
        } else if uiWebView != nil {
            if uiWebView!.canGoBack {
                uiWebView!.goBack()
            }
        }
    }
}

あぁ、目も眩むようなひどいコードです。開発を引き継いでこんなコードを目の当たりにしたら初日からやる気を失ってしまいます。

では ViewController を WKWebView 対応版と非対応版で二つ用意したら良いでしょうか。クラス単体としてはキレイになったように見えますが、この先の保守コストが2倍になってしまうのは嫌です。じゃあそれらの共通処理を親クラスとして切り出せば…うーん、クラスがやたら分厚くなっていくのも好ましくありません。

じゃあ、どうするか

そもそも UIWebView をサポートしたいのは WKWebView が浸透しきるまでの間だけです。いずれ iOS 9, iOS X (?) と進化して行くにつれ、インストールベースの大半は WKWebView をサポートするようになっているでしょう。

そこで、UIWebView にはしばらく WKWebView のように 振る舞ってもらって、いずれ役割を終えたらスッと切り離す…そんな風にコードを書くことができれば、開発者としては過去に縛られることなく、また誠意の限り下位バージョンもサポートできて幸せではありませんか。

どうしたらいいでしょう。

こういうことは堅牢(けんろう)な Swift よりもわりとなんでもアリな Objective-C の方が解決しやすかったりします。ここでは UIWebView と共にいずれは消えてゆく先輩言語の力を借りつつ、ちゃんと Swift でも動くように実現してみようと思います。

1. 共通化したい処理をプロトコルで切り出す

まず WebView として使いたい機能のうち、WKWebView が持っているインターフェースをそのままプロトコルとして取り出してきます。

@protocol WKWebViewProtocol 

@property (nonatomic, readonly, copy) NSURL *URL;
@property (nonatomic, readonly, copy) NSString *title;

@property (nonatomic, readonly) BOOL canGoBack;
@property (nonatomic, readonly) BOOL canGoForward;

- (void) goBack;
- (void) goForward;
...

@property (nonatomic, readonly) UIView *asView;

@end

最後に asView というプロパティーを追加しています(理由は後で)。WKWebView は asView 以外のメソッドは元から実装されているので、次のような拡張カテゴリーを作っておきましょう:

@interface WKWebView(WKWebViewProtocolConformed) <WKWebViewProtocol>

@end

@implementation WKWebView(WKWebViewProtocolConformed)

- (UIView *)asView
{
    return self;
}

@end

厳密には WKWebView の goBack, goForward の戻り値の型は void ではないのですが、Objective-C はそういったことには寛容です。先に進みましょう。

2. UIWebView を拡張する

次に、カテゴリー拡張によって UIWebView を上のプロトコルに適合させていきましょう。

@interface UIWebView(WKWebViewProtocolConformed) <WKWebViewProtocol>

@end

@implementation UIWebView(WKWebViewProtocolConformed)

// ここを埋めていきます

@end

まず URL, title プロパティーはお馴染みの JavaScript で問い合わせる方法で実装できます:

- (NSURL *)URL
{
    NSString *URLString = [self stringByEvaluatingJavaScriptFromString:@"document.URL"];
    return [NSURL URLWithString:URLString];
}

- (NSString *)title
{
    return [self stringByEvaluatingJavaScriptFromString:@"document.title"];
}

canGoBack, canGoForward, goBack, goForward については、同じメソッドが UIWebView で定義されているので実装する必要がありません。

このように、WKWebView にあって UIWebView にないメソッドを補完していくことで、UIWebView は WKWebView と同じインターフェースで同等の機能を持つようになってきます。

最後に asView を WKWebView の場合と同様に実装しておきます:

- (UIView *)asView
{
    return self;
}

以上で準備は終わりです。

3. そして ViewController の実装へ

まず Swift から上の Objective-C コードを使うために、プロジェクトの Bridging-Header の中でインポートしておきます:

#import "WKWebViewProtocol.h"
#import "WKWebView+WKWebViewProtocolConformed.h"
#import "UIWebView+WKWebViewProtocolConformed.h"

さて、元の MyViewController では、冒頭で各 WebView のインスタンスを別々に宣言していました:

class MyViewController: UIViewController {

    var wkWebView: WKWebView?
    var uiWebView: UIWebView?

これを WKWebViewProtocol インスタンスとして一つにまとめます:

class MyViewController: UIViewController {

    var webView: WKWebViewProtocol!

元の実装ではどちらか一方は nil となるので Optional として ? をつけて宣言していましたが、今回は変数 webView は必ず実体を持つようになるので、Implicitly Unwrapped Optional として ! をつけて宣言しています。これでプロパティーやメソッドアクセスのたびに ?! をつける必要がなくなります。

続く viewDidLoad はこう書き換えます:

    override func viewDidLoad() {
        super.viewDidLoad()

        // iOS8以上と7以下の場合で、別々にインスタンスを作る
        if NSClassFromString("WKWebView") != nil {
            webView = WKWebView(frame: )
        } else {
            webView = UIWebView(frame: )
        }
        self.view.addSubview(webView.asView)
    }

おぉ、UIWebView と WKWebView が 同じ変数 に格納できているではありませんか!

それは変数 webView の型を先ほど作ったプトロコル WKWebViewProtocol としていて、UIWebView, WKWebView がともにこのプロトコルに適合するように拡張したからでした。

addSubview(webView.asView) としているのは、webView は実体としては WKWebView か UIWebView なので UIView クラスではあるのですが、型はプロトコルとして宣言されているのでこのように取り出す必要があるためです。ちなみに Objective-C の場合は webViewUIView<WKWebViewProtocol>* 型にできるので asView は不要です。

続けて、戻るボタンの型ごとに分岐していた処理はこのように一つにまとまります:

    @IBAction func goBackButtonTapped() {
        if webView.canGoBack {
            webView.goBack()
        }
    }

ロジック通りのあるべき姿になりました!

以上をまとめて、さらに viewDidLoad 内で webView を生成している部分も別のクラスに切り出せば、MyViewController の実装はこうなります:

class MyViewController: UIViewController {

    var webView: WKWebViewProtocol!

    override func viewDidLoad() {
        super.viewDidLoad()

        webView = MyWebViewBuilder.webView(frame: )
        self.view.addSubview(webView.asView)
    }

    @IBAction func goBackButtonTapped() {
        if webView.canGoBack {
            webView.goBack()
        }
    }
}

ViewController から UIWebView と WKWebView の分岐処理を 完全に取り除く ことができましたね!

delegate はどうする?

ストイックにやろうと思えば、UIWebView 自身が UIWebViewDelegate メソッドを受け取るようにし、対応するWKNavigationDelegate メソッドに置き換えて外へ飛ばすようにすることもできるでしょう。

ただそこまでしなくても、 ViewController で両方の delegate メソッドを互いに依存しないように作っておいて、将来的に UIWebView を切り離すタイミングで UIWebViewDelegate の部分だけ丸っと消してしまえば済む話なので、SmartSearch では次のような作りにしています:

class MyViewController: UIViewController, WKUIDelegate, WKNavigationDelegate, UIWebViewDelegate { // WKWebView, UIWebView の delegate を両方実装する
    var webView: WKWebViewProtocol!

    override func viewDidLoad() {
        super.viewDidLoad()

        webView = MyWebViewBuilder.webView(frame: , delegate:self)
        self.view.addSubview(webView.asView)
    }

    ...

    // MARK: WKNavigationDelegate, WKUIDelegate
    func webView(webView: WKWebView, decidePolicyForNavigationAction navigationAction: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void) {
        ...
    }

    ...

    // MARK: UIWebViewDelegate (UIWebViewサポート終了時に丸っと消す)
    func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
        ...
    }

    ...
}

まとめ

UIWebView と WKWebView を共存させるプロジェクトで、Objective-C のテクニックをうまく使うことで両者の分岐処理を奇麗に隠蔽(いんぺい)することができました。(Swift だけで同じことをやろうとしたら、戻り値の型の違いでコンパイルエラーとなってうまく行きません)

iOS は中身だけでなく API もどんどん変化していくので、うまくレガシーコードを切り離していかないとすぐに進化に追いつけなくなってしまいます。それを負担と見るのではなく、いろいろなテクニックを活用して負債をさばいていけるようになれば普段の開発ももっとクリエイティブで楽しくなると思います。

「もっと楽しくなる」といえば、ヤフーの新しい検索アプリ SmartSearch「検索はもっと楽しくなる」 をコンセプトとして、スマートフォンで検索する行為そのものをもっと発見に満ちた楽しいものにしたいという思いで作られています。上のやり方も実際のプロダクトで使われているので、iOS 7 と 8 で動かして見比べてみてもらえたりしたらうれしいです。

それでは、2014年も残りわずかですが、これからも良いコードを書き、世界を変えていきましょう。

SmartSearch-logo

-------
IOSの商標は、Ciscoの米国およびその他の国のライセンスに基づき使用されています。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加