ぴよログ

↓に移転したのでこっちは更新されません、多分。

RailsアプリにiOSクライアントをサッと作る #sgadvent

f:id:xoyip:20151205223720j:plain

この記事はソニックガーデン Advent Calendar 2015 、6日目の記事です。

どうも大野(@xoyip)です。3日目に続いて登場させてもらいます。今日は5日目までとは少し変わって技術的なHow Toの話で、自分用の覚書でもあります。

ちなみに3日目のはこれ。

xoyip.hatenablog.com

ソニックガーデンではお客さまが実現したいサービスを開発するのにRuby on Railsを使っています。PCやスマートフォンのブラウザから利用されるようなサービスを作ってきました。

ところが最近のスマートフォン普及率などから、スマートフォンのネイティブアプリケーションを用意してユーザーに使ってもらいたいというお客さまのご要望も増えてきています。社内でも開発や運用についてどうしていくかの議論が盛んになってきています。

そういう流れから今日はすでに運用しているRailsアプリケーションにiOSクライアントを作るには、という話をサンプルコードで簡単に紹介できればと思います。

今回の事例

Facebookログインを利用した既存のWebサイト(Rails)用のiOSクライアントを作りたい」というケースを考えてみます。

やらなければならないことは

この2つです。

またサンプルコードをGitHubに用意したので、RubyXcodeがあれば実際に動かすことができます。

github.com

github.com

Railsスマートフォン用の認証口を用意する

doorkeeperの導入

まずは外部のクライアントが認証できる仕組みをRails側に用意します。doorkeeperというgemを使うと、超簡単にOAuthプロバイダーの機能をRailsアプリケーションに追加することができます。

https://github.com/doorkeeper-gem/doorkeeper

既存のRailsアプリケーションに対して次のステップを実施すれば簡単に導入可能です。

$ gem 'doorkeeper'
$ bundle
$ rails generate doorkeeper:install
$ rails generate doorkeeper:migration
$ rake db:migrate

あとはroutes.rbに追記すれば大体おわり。

# config/routes.rb

Rails.application.routes.draw do
  # 他のroutes
  use_doorkeeper
end

※設定は上のステップで生成されたconfig/initializers/doorkeeper.rbで。色々あるので説明は省略!

OAuth Applicationの作成

先ほどのセットアップがうまくいっていれば/oauth/applicationsにアクセスして次のような画面を開くことができるようになります。この画面から新しいアプリケーションを作成していきます。

f:id:xoyip:20151205223259p:plain

必要なパラメータを入れます。名前は識別できればなんでもよくて、リダイレクトURIにはiOS側で設定するものと同じものを入れます。今回はintegration-sample-ios://oauth-callback/iosを使うことにしました(スキーマ以外が合っていれば、path などは必要に応じて変えればよいです)。

追記

development環境以外ではURLスキームがhttpsでないとエラーとなります。それを回避するためにはconfig/initializers/doorkeeper.rbforce_ssl_in_redirect_uri falseとしておけば良いです。

追記おわり

f:id:xoyip:20151205223308p:plain

作成するとアプリケーションIDやトークンが発行されます。iOS側で使うので控えておきます。

f:id:xoyip:20151205223320p:plain

APIを用意する

Railsアプリケーション側にJSON APIがなければ作っておきます。今回はiOSからログインできていることを確認するために、ログインしているユーザーの名前やメールアドレスを返すAPIを用意します。

まずAPI用のApplicationController的なものを用意します。

API用のコントローラの全てのアクションでdoorkeeper_authorize!で認証をかけて、ヘルパーメソッドcurrent_userで認証したユーザーを得られるようにしておきます。

# app/controllers/api/api_controller.rb 
class Api::ApiController < ApplicationController
  before_action :doorkeeper_authorize!
  helper_method :current_user

  def current_user
    User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
  end
end

最後に、名前とメールアドレスを返すAPIを定義します。

# app/controllers/api/users_controller.rb  
class Api::UsersController < Api::ApiController
  def show
    @user = current_user
  end
end
# app/views/api/users/show.json.jbuilder
json.extract!(@user, :id, :name, :email)

これをiOSから呼び出せれば連携成功!となるわけです。

iOSクライアントを作る

iOSアプリでは起動時にログイン済みかどうかを判定し、ログイン済みであればAPIを叩いて自分の情報を表示、そうでなければログイン画面を表示という流れになるようなものを作ります。

pod install

ライブラリをいくつか使うのでそれらをインストールします。

# Podfile

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '9.0'
use_frameworks!

pod 'Alamofire', '>= 2.0' # APIを呼ぶときに
pod 'SwiftyJSON', '>= 2.3' # APIのレスポンスの処理に
pod 'p2.OAuth2', '~> 2.0.0' # OAuth2の認証に
pod 'KeychainAccess' # トークンの保存に

※iOS9からhttpの通信で叱られるようになります。それだと開発中に困るのでApp Transport Securityの設定でhttpを許すドメインを指定してあげたほうが便利です。

mushikago.com

※p2.OAuth2ライブラリ内のコードに、開こうとしているURLがhttpのときはassertで止まるような記述があります。あまりよろしくないですが開発中はコメントアウトしています。

URLスキームを設定する

p2.OAuth2を使った認証の流れですが、

  1. doorkeeperで取得したIDやトークンを設定する
  2. SafariまたはSafariViewを開いてログインを求める
  3. URLスキームを使ってアプリに戻る

といった感じになっています。doorkeeperで設定したリダイレクトURIは認証後にアプリに戻るために使われます。

URLスキームの設定方法はこんな感じ。

f:id:xoyip:20151205223345p:plain

認証する

認証のところのコード(全体像はサンプルコードを見てもらったほうがよいと思います)。

var oauth2 : OAuth2CodeGrant?

let settings = [
    "client_id": "YOUR_APP_ID",
    "client_secret": "YOUR_APP_SECRET",
    "authorize_uri": "YOUR_SERVER_URL" + "/oauth/authorize",
    "token_uri": "YOUR_SERVER_URL" + "/oauth/token",
    "scope": "",
    "redirect_uris": ["integration-sample-ios://oauth-callback/ios"],
    "keychain": false,
] as OAuth2JSON

// OAuthアプリケーションのトークンなどを使って認証用のオブジェクトを生成
oauth2 = OAuth2CodeGrant(settings: settings)

// アプリ内に埋め込みでSafariViewを出す
oauth2?.authConfig.authorizeEmbedded = true

// 成功時
oauth2?.onAuthorize = { parameters in
    let json = JSON(parameters)
    // トークンを保存
    let keychain = KeychainAccess.Keychain(service: "YOUR_BUNDLE_ID")
    try! keychain.set(json["access_token"].stringValue, key: "access_token")
}

// 失敗時
oauth2?.onFailure = { error in
    print(error)
}

そして最初のビューが表示されたタイミングで、ここで生成したoauth2オブジェクトを使ってログイン画面を表示します。

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    let keychain = KeychainAccess.Keychain(service: "YOUR_BUNDLE_ID")
    // トークンが保存されているかどうかでログイン済みかどうかを判定する
    if let accessToken = try! keychain.get("access_token") {
        // APIを呼ぶ
    } else {
        // contextとして自身(UIViewController)を指定する
        Auth.sharedInstance.oauth2?.authConfig.authorizeContext = self
        // ログイン画面を出す
        Auth.sharedInstance.oauth2?.authorize()
    }
}

最後に、認証完了後、URLスキームでアプリに戻ってきたことをoauth2オブジェクトに教えてあげることで成功時のコールバックonAuthorizeが呼ばれます。

// AppDelegate.swift
func application(application: UIApplication, openURL url: NSURL, sourceApplication: String?, annotation: AnyObject) -> Bool {
    if let path = url.path {
        if path.hasPrefix("/ios") {
            Auth.sharedInstance.oauth2?.handleRedirectURL(url)
            return true
        }
    }
    return false
}

なお今回はoauth2オブジェクトを各所から参照するためにシングルトンを用いています。

APIを使う

ここまでくれば、あとは取得したトークンでAPI呼び出しをするだけです。

ApiClient.sharedInstance.get("/user", parameters: [:], onSuccess: { json in
    let alertController = UIAlertController(title: "ログイン成功", message: "\(json["name"])\nとしてログインしました", preferredStyle: .Alert)
    let defaultAction = UIAlertAction(title: "OK", style: .Default, handler: nil)
    alertController.addAction(defaultAction)
    self.presentViewController(alertController, animated: true, completion: nil)
}, onFailure: { error in
    print(error)
})

jsonにはRailsが返したid、名前、メールアドレスが入っています。アラートで表示(手抜き)してきちんと取得できるのが確認できるはずです。

ちなみにAPIクライアントはこんな感じのコードです。本当のアプリケーションではもっと汎用的に使えるような書き方をしたほうが良いですが、サンプルなのでサッと書いてしまいました。

// ApiClient.swift
class ApiClient: NSObject {
    static let sharedInstance = ApiClient()
    let endpoint : String = "YOUR_SERVER_URL/api"
    
    func get(path: String, parameters: Dictionary<String, String>, onSuccess: (JSON)->Void, onFailure: (NSError)->Void) {
        guard let url = NSURL(string: endpoint + path) else {
            return
        }
       
        let headers = [
            "Authorization": "Bearer \(Auth.sharedInstance.accessToken()!)"
        ]
        
        Alamofire.request(.GET, url, headers: headers, parameters: parameters).responseJSON { response in
            if response.result.isSuccess {
                if let value = response.result.value {
                    onSuccess(JSON(value))
                }
            } else {
                if let error = response.result.error {
                    onFailure(error)
                }
            }
        }
    }
}

これでiOSアプリケーションとRailsの連携ができるようになりました。ライブラリに頼りっきりではありますが、その分連携するまでをサッと作ることができます。アプリケーションそのものに時間を使ったほうがいいですからね。

何回か書いていますが、サンプルコードを見てもらったほうがわかりやすいと思います。

SwiftでMicrosoft Azure StorageのAPIクライアントを書きました(書いてます)

最近はSwift版のMicrosoft Azure StorageのAPIクライアントを書いている。

github.com

Microsoft Azure Storageを管理するようなMacまたはiOSのアプリを書きたくてSwiftObjective-Cで書かれたAPIクライアントライブラリを探したのだけれど、現時点ではそのようなものはないということがわかったのでSwiftを勉強しつつ自分で書くことにした(ここにそう書かれている→Microsoft Azure Storage Client Library for C++ v1.0.0 (General Availability) - Microsoft Azure Storage Team Blog - Site Home - MSDN Blogs

このクライアントを使って書いたMacアプリは現在申請中でレビュー待ち。というか一回リジェクトされてしまった。

そもそもなぜMacアプリを書く必要があるのか、そしてそもそもなぜAWSではなくAzureなのか、ということに答える必要があるかもしれないけど、今回はその点はスルーすることにする。

MS Azure Storage

(ここ、間違っていたらごめんなさい)

MS Azure StorageにはBlob、Queue、Table、Fileという4つのサービスの総称で、AWSと対応させると大体こんな感じになっている。

Azure Storage AWS 用途
Blob S3 ファイル
Queue SQS メッセージング
Table DynamoDB NoSQL
File (ない?) ファイル共有

今回書いているAPIクライアントはこれら4つのサービスのうちBlobとQueueに対応しようと考えていて、実際主要なAPIはある程度実装できたと思っている。

ちなみにAzure StorageのAPIリファレンスはこちら。

ストレージ サービス REST API リファレンス | Windows Azure のテクニカル ドキュメント ライブラリ

設計

Swift力不足のためAPIクライアントをSwiftでいい感じに書くにはどうしたらいいのか全く検討がつかなかった。そこで「Swift API クライアント」などと適当にググって調べらとてもいい記事を発見し、記事やサンプルアプリケーションを見て良い感じだなと思ったのでほぼそのままの設計で実装することにした。この記事に感謝します。

堅牢で使いやすいAPIクライアントをSwiftで実装したい

設計方針はこの3つだそう。

目標としたのは以下の3つの条件を満たすことです。

  • レスポンスはモデルオブジェクトとして受け取る (便利)
  • 個々のリクエスト/レスポンスの定義は1箇所で済ます (変更しやすくしたい)
  • リクエストオブジェクトはAPIクライアントから分離させたい

この記事を読んでいただければ僕のライブラリでやっていることも全部わかると思うが、一応簡単に設計について触れておく。

クライアントの主要コード

クライアントの主要メソッドcall擬似コードを載せてみる。

public class Client {
    public func call<T: Request>(request: T, handler: (Response<T.Response>) -> Void) {
        // ① 成功時の処理
        let success = { (task: NSURLSessionDataTask!, responseObject: AnyObject!) -> Void in
            let statusCode = (task.response as? NSHTTPURLResponse)?.statusCode
            switch (statusCode, request.convertResponseObject(responseObject)) {
            case (.Some(200..<300), .Some(let response)):
                handler(Response(response))
            default:
                let userInfo = [NSLocalizedDescriptionKey: "unresolved error occurred."]
                let error = NSError(domain: "WebAPIErrorDomain", code: 0, userInfo: userInfo)
                handler(Response(error))
            }
        }

        // ② 失敗時の処理
        let failure = { (task: NSURLSessionDataTask!, error: NSError!) -> Void in
            handler(Response(error))
        }

        // ③ リクエスト
        let manager = AFHTTPSessionManager()
        let url = scheme + "://" + host() + request.path()
        manager.responseSerializer = AFHTTPResponseSerializer()
        manager.responseSerializer.acceptableContentTypes = request.responseTypes()
        manager.GET(url, parameters: nil, success: success, failure: failure)
    }
}

③の部分は単にAFNetworkingの呼び出しなのでここでは特に触れない。通信のライブラリとしてはAFNetworkingを選択した。同じ作者が書いたSwift版のライブラリ、AlamofireのほうがSwiftらしく書けるのかもしれないけれど、一度も使ったことがなかったので今回はパスした。いずれ書き換えてみてもいいかもしれないと思っている。

次は短い②の部分だけど、これは単にエラーオブジェクトをcallに渡ってきたハンドラに返している。

最後の①はレスポンスが正常に返ってきたときの処理で、ステータスコードとレスポンスから正しくモデルオブジェクトに変換できたかどうかで処理を分岐している。

全体を見ると、個々のリクエストに必要な情報(メソッド、パラメータ、HTTPレスポンスから得たいモデルオブジェクトなど)はリクエストオブジェクトから取り出して使うようになっている。そのためクライアントのコードは全てのリクエストに共通の処理だけ書いておけば良い。

レスポンス

クライアントのcallメソッドに渡すハンドラにはResponse<T>が渡ってくるようになっている。このResponseは値付きenum(というのかなんというのか…?)になっていて、成功時はリクエストオブジェクトが持つレスポンスの型に対応したモデルオブジェクトが、失敗時にはNSErrorを持っていることになる。

public class Wrapper<T> {
    public let value: T
    
    init(_ value: T) {
        self.value = value
    }
}

public enum Response<T> {
    case Success(Wrapper<T>)
    case Failure(Wrapper<NSError>)
    
    init(_ value: T) {
        self = .Success(Wrapper(value))
    }
    
    init(_ error: NSError) {
        self = .Failure(Wrapper(error))
    }
}

クライアントの呼び出し側はこのようになる。response: Response<T>でSwitchして成功時、失敗時の処理をしてあげる感じになる。

client.call(AzureQueue.ListQueuesRequest(), handler: { response in
    switch response {
    case .Success(let wrapper):
        println(wrapper.value) // AzureQueue.ListQueuesRequest.Response
    case .Failure(let wrapper):
        println(wrapper.value) // NSError
    }
})

リクエストオブジェクト

さっき書いたようにリクエストオブジェクトには個々のHTTPリクエストで必要な情報を個別に定義して、クライアントから使えるようにしてある。

参考にした記事では

  • パス
  • メソッド(GET,POSTなど)
  • モデルオブジェクトへの変換
  • モデルオブジェクトへの型

などが書かれていたが、Azure Storageのクライアントではさらに、

  • リクエストBody
  • 追加のHTTPヘッダー(BodyのContent-Lengthなど)
  • HTTPレスポンスのContent−Type

あたりを追加している。

public class ListQueuesRequest:  Request {
    public let method = "GET"

    public typealias Response = Collection<Queue>

    public init() {}

    public func path() -> String {
        return "/?comp=list"
    }

    public func body() -> NSData? {
        return nil
    }

    public func additionalHeaders() -> [String : String] {
        return [:]
    }

    public func convertResponseObject(object: AnyObject?) -> Response? {
        return ResponseUtility.responseItems(object, keyPath: "Queues.Queue")
    }

    public func responseTypes() -> Set<String>? {
        return ["application/xml"]
    }
}

Promise版の呼び出し

ここまで書いたことでAPIクライアントとしての機能は大体果たせるようになった。

ところでJavascriptなんかではよくあるように、非同期処理を待ってから次の非同期処理を書こうとするととても書きにくいという問題が、Objective-CSwiftAPIクライアントを書くときにも現れる。これはまあ放っておいてもいいのだけど使うときに便利なほうがいいと思ったので試しに対応してみることにした。

SwiftでもJavascriptのPromise的なアプローチを使えることができると知っていたので調べてみたところ次のようなライブラリが候補に挙がった。

決め手はなんだったかよく覚えていないが、上に挙げた3つのライブラリを全て試してみて最終的にBrightFuturesを採用してみた。

実装の際に参考にしたリンクはこちら。

BrightFutures版のクライアント

public class Client {
    public func future<T: Request>(request: T) -> Future<T.Response, NSError> {
        let promise = Promise<T.Response, NSError>()

        // ① 成功時の処理
        let success = { (task: NSURLSessionDataTask!, responseObject: AnyObject!) -> Void in
            let statusCode = (task.response as? NSHTTPURLResponse)?.statusCode
            switch (statusCode, request.convertResponseObject(responseObject)) {
            case (.Some(200..<300), .Some(let response)):
                promise.success(response)
            default:
                let userInfo = [NSLocalizedDescriptionKey: "unresolved error occurred."]
                let error = NSError(domain: "WebAPIErrorDomain", code: 0, userInfo: userInfo)
                promise.failure(error)
            }
        }

        // ② 失敗時の処理
        let failure = { (task: NSURLSessionDataTask!, error: NSError!) -> Void in
            promise.failure(error)
        }

        // ③ リクエスト
        let manager = AFHTTPSessionManager()
        let url = scheme + "://" + host() + request.path()
        manager.responseSerializer = AFHTTPResponseSerializer()
        manager.responseSerializer.acceptableContentTypes = request.responseTypes()
        manager.GET(url, parameters: nil, success: success, failure: failure)

        // ④ Futureオブジェクトを返す
        return promise.future
    }
}

通常版と変わったのはcallメソッドにハンドラを渡さなくなり、代わりにFutureというオブジェクトを返すようになったことで、レスポンスが返ってきたときにはハンドラにモデルオブジェクトやエラーを渡す代わりにPromiseオブジェクトのsuccessfailureメソッドを呼ぶようになっている。

BrightFutures版を使う側のコード

例えば、

  1. Queueの一覧を取得した後で、
  2. Queueを新しく生成し、
  3. 次にそのQueueを削除する

というAPI呼び出し(意味はないが)をしたいとき、通常版ではこのようになる(ひどすぎる…)。

func onError(error: NSError) {
    println(error)
}

func normal() {
    let req1 = AzureQueue.ListQueuesRequest()
    queueClient.call(req1, handler: { response in
        switch response {
        case .Success(let wrapper):
            let req2 = AzureQueue.CreateQueueRequest(queue: "brandnewqueue")
            self.queueClient.call(req2, handler: { response in
                switch response {
                case .Success(let wrapper):
                    let req3 = AzureQueue.DeleteQueueRequest(queue: "brandnewqueue")
                    self.queueClient.call(req3, handler: { response in
                        switch response {
                        case .Success(let wrapper):
                            println("Success!!")
                        case .Failure(let wrapper):
                            self.onError(wrapper.value)
                        }
                    })
                case .Failure(let wrapper):
                    self.onError(wrapper.value)
                }
            })
        case .Failure(let wrapper):
            self.onError(wrapper.value)
        }
    })
}

BrightFutures版では煩雑さは残るものの幾分かマシに書けるようになる。

func promise() {
    let req1 = AzureQueue.ListQueuesRequest()
    queueClient.future(req1).flatMap { response -> Future<AzureQueue.CreateQueueRequest.Response, NSError> in
        let req = AzureQueue.CreateQueueRequest(queue: "brandnewqueue")
        return self.queueClient.future(req)
    }.flatMap { response -> Future<AzureQueue.DeleteQueueRequest.Response, NSError> in
        let req = AzureQueue.DeleteQueueRequest(queue: "brandnewqueue")
        return self.queueClient.future(req)
    }.onSuccess { response in
        println(response)
    }.onFailure { error in
        println(error)
    }
}

煩雑さの原因になっているflatMap { response -> Future<AzureQueue.CreateQueueRequest.Response, NSError> inのようなクロージャの型の部分だが、これを省略してしまうと現状のXcode6.4ではambiguousとか言われてコンパイルできなかった。PromiseKitなどでも同じように見えるエラーに出会ったのでこのあたりはSwiftXcodeの進化が必要なのかな。

とにかく、これでAPI呼び出しを順番にしたいという要求にも一応答えられるようになった。

あとPromise系のライブラリはそれぞれ進化が速いらしい。すぐに色々変わってしまうかもしれない。

テスト

プロジェクトをCocoaPodsのpod lib createで生成したらデフォルトでQuickというテストライブラリが入ってきたのでそのままこれを使った。Quickでのテストは書いたことがなかったが参考になるコード(後述)が見つかったのであまり苦労することはなかった。

テストはできるだけ書いておきたいと思ったものの、APIクライアントはAPIサーバーあっての話なのでテストはどうしようか迷って色々試した結果、今のように実際のAzure環境を使ったテストに落ち着いた。

Nocillaでスタブ?

最初はNocillaというライブラリを使ってスタブしてテストを書こうと思ったが、マッチポンプ的あまり意味がない気がしたのでこれは却下することにした。

なお、Nocillaを使おうとして色々調べているときに見つけたサンプルコードがQuickでテストを書くときにも訳にたったので紹介しておく。

Azure Storage Emulator

次に試そうとしたのはWindowで動かすことができるAzure Storage Emulatorを使うことだ。ざっくり言うとAPIサーバーのエミュレータをローカルに立てられるというものらしい。ということはこれをMacに入れたVMWareとかで動かしておけばテストに使えるんじゃねーか(少なくともローカルでは)と考え色々準備してみた。

ところがクライアントの接続先にVMWareのIPを指定してもどうにもうまくいかない。3時間近く奮闘したものの何も得られなかったのでこの作戦も却下することに。

実環境でテストする

ここでようやく他の言語のクライアントではどうしてるんだろうということに気がついたので、Railsで使ったことがあるRuby版のクライアントを見てみることにした。

Azure/azure-sdk-for-ruby

これのテストコードを見ると環境変数にストレージのアカウントやアクセスキーが設定されているときだけテストを動かせるようになっていた。つまり本物のAzure環境でテストを動かすよ、ということのようだった。

そういうわけでSwift版でも同じようにAzure環境でテストを動かすコードを書くことになった。テストコードを書くにあたってBrightFutures版が非常に役に立った。これをしてそれをして、最後にあれをしたらこうなっている、というコードを通常版で書こうとしていたらだいぶ辛いことになっていたと思う。

コード

もう1回貼っておきます。

github.com

宣伝

途中に出てきた表は自作のMacアプリで作りました。日本語入力で若干不具合があるけどMarkdownで表を書くときにはWebにある表生成ツールMarkdown Tables generator)よりも便利なのでぜひどうぞ。

Table2Text (Markdown, CSV)
カテゴリ: 開発ツール, ユーティリティ

詳解 Swift

詳解 Swift

Cocoaアプリケーション(OSXアプリ)はじめました

実験的な意味も含めてCocoaアプリの開発を始めた。完成したものはMac App Storeに並べてもらうべくiOSと同じように申請して審査してもらっている。

そもそもMac App Storeに並んでいるアプリはなんと呼ぶのが正解なのかすらよくわかっていないが、Cocoaアプリということにしておく。

iOSの開発は5年ぐらいやっているけれど、Cocoaアプリの開発は全くの初めてだったから調べながら進めている。今は単機能のSingle Windowアプリを3つ作った程度で、ドキュメントベースのアプリケーションのような複雑なことはしていないが、Cocoaアプリ開発についてのコメントというか感想を書いてみようと思う。ただの感想文ですよ。

情報が少ない、見つけにくい

まず言えるのはこれだと思う。iOS開発の情報はネットの世界に溢れている。日本語でも大量の情報があるから大抵のことは調べればできる。

その反面、Cocoaアプリケーションの情報はなかなか見つからない。日本語で書かれたそこそこ新しい情報がたまにヒットするが、大抵はAppleのドキュメントページが検索のトップに来る。そしてその情報も数年前に書かれたものだったりする(必ずしも情報が古いというわけではなく、SDKが変わっていないというだけだったりするが)。

検索自体もしづらい。例えばAVFoundation OSX Swift ○○とか調べようとするとiOSの検索結果がずらりと出てくる。iOSとは独立してはっきりしたネーミングがあればよかったのにと思う。

最近気がついたのは、Cocoaというキーワードを入れておくとなかなか良さそうな検索結果を得られる気がする。

お作法が割と違う

UI周りがまあまあ違う。iOSのように画面サイズを制限されない分、自由にビューを配置したり複数のウィンドウを使ったり色々なことができる。

例えばiOSではUIImageを使っているところをCocoaではNSImageを使うことになるんだけど、このクラスのインターフェースが微妙に異なるので同じように使えるもんだと思っているといちいち躓く。

で、ドキュメントを探したり例を探そうとすると最初の話「情報が見つからない」に行き着いて時間がかかる。

慣れるまでは同じことを何度も調べる可能性があるので見つけたページはEvernoteのWebClipすることにしている。この機能は最近全く使っていなかったけれど久しぶりに重宝しそうだ。

iOSの経験はかなり活きる

お作法が微妙に異なるとは言え、SDKの思想はiOSとほぼ変わらないからiOS経験者は比較的すんなり入ることができる。

例えば、縦横の表を表現するときにはNSTableViewを使うが、iOSの経験のない人がいきなりこのViewを使おうとしたら混乱するんじゃないかと思う。予想です。

iOSUITableViewもこのNSTableViewdelegatedataSourceを用いてコンテンツや挙動を制御するわけだけど、行と列がある分NSTableViewのほうが複雑と言っていいと思う。先に複雑じゃないUITableViewを触っていれば、比較的すんなり入れるんじゃないかな。

Cocoaアプリケーション開発でもStoryBoardやSegueのようなUI開発の仕組みがあるし、Cocoapodsなども活用できるからiOS経験者にはハードル低いんじゃないかな。みんなやったほうがいいよ。

おわり

これからは備忘録的にちょくちょく書いていく予定。

ちなみに最初に作ったのはiPhoneiPadのAppStoreで使えるPreview動画を自動生成するアプリ。ネーミングやデザインなどはお察しだけどなかなか便利なのができたよ。

Movie Resize for App Previews
カテゴリ: ユーティリティ, グラフィック&デザイン

MAC OS X COCOAプログラミング 第4版

MAC OS X COCOAプログラミング 第4版

UIWebView内リンクでの遷移をNavigationでやるやつ、Swiftバージョン

iOSでUIWebView内のリンクをタップしたらNavigationで画面遷移させる実験 - PILOG

↑こんなん書いたけどこのときはObjective-Cで書いてたのでSwiftで書き換えてみました。オプショナルが全然慣れませんがこれから勉強します。

import UIKit
import TKRSegueOptions

class ViewController: UIViewController, UIWebViewDelegate {
    
    let HOST = "http://localhost:4000"

    @IBOutlet weak var webView: UIWebView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        webView.delegate = self
        var urlstring = HOST + "/"
        if let path = self.segueOptions?["path"] as? String {
            urlstring = HOST + path
        }
        let url = NSURL(string: urlstring)
        let req = NSURLRequest(URL: url!)
        webView.loadRequest(req)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
        
        if(request.URL?.scheme == "callback"){
            println(request.URL?.absoluteString)
            let urlstring = request.URL?.absoluteString
            let href = urlstring?.substringFromIndex(advance(urlstring!.startIndex, 11))
            performSegueWithIdentifier("next", options: ["path": href!])
        }
        return true
    }
}

詳解 Swift

詳解 Swift

iOSでUIWebView内のリンクをタップしたらNavigationで画面遷移させる実験

しばらくiOSから離れていてSwiftはほとんど触ったことないのでとりあえずObjective-Cでやりました。このあとSwiftで実装し直そうと思ってるけれど、一旦エントリにします。

いつだったかDHHが既存のWebサイトを活かしてアプリ作るよーみたいなことを言ってたのを覚えていたので、今回は「いや、実際どうやってやるんだろうな」というのを考えてみたわけです。

何をやりたいか

こういうページ遷移がある普通のWebサイトがあるとして、

このページをiOSのUIWebViewで表示するんだけど、リンクをタップしたときに新しいViewControllerを作ってNavigationで遷移するということをやってみます。普通にやるとリンクをタップしたら同一WebView内画面遷移するところを、あたかもiOSのネイティブのように動かしてみようということね。

結論からいうとそれは可能です。↓のアニGIFを見てもらえばリンクを押したときにナビゲーションで次のビューがスタックしてくるのがわかると思います。

こうすることでWebにコンテンツを自由にデプロイしつつ、ユーザー体験はネイティブに近づけられるんじゃないかと。

こんなことはすでに昔からみんながやってたりしたかもしれないけど、最近は端末も速くなっているし、個人的にはWebView+ナビゲーションってあまり見たことがない気がするのでその部分の実験ということで見てもらえると良いです。

Webページの準備

まずはHTML側のソースです。ここで見るべきは、<a>タグについてるcallback-to-iosクラスぐらい。

<!DOCTYPE html>
<html>
  <body>
    <p>1ページ目</p>
    <a href="/secondpage.html" class="callback-to-ios">2ページ目ヘ</a>
  </body>
</html>

続いてJavascriptです。こっちが肝ね。

$ ->
  reportBackToIOS = (href)->
    iframe = $('<iframe />').attr('src', "callback://" + href)
    $('body').append(iframe)
    iframe.remove()
    iframe = null

  $('.callback-to-ios').click ->
    reportBackToIOS($(@).attr("href"))
    false

まずリンクのクリック時にreportBackToIOSを呼び出した上で、falseを返してリンクを辿らないようにしてあります。

reportBackToIOSは名前の通りiOSにリンクがタップされたことを知らせる役割を担っています。iOSのUIWebViewではWebView内でURLの読み込みが発生する直前にコールバックを通るんですが、ここではそれを利用しています。

具体的には、iOSに渡したい情報をURL(の一部)として持ったiframeを作って一旦HTMLのbodyに追加し、すぐに削除していますね。iframeがbodyに追加されたとき、srcアトリビュートで持っているURL(ここでは、'callback://' + 遷移先のURL)をiframe内で開こうとするため、WebViewのコールバックが呼ばれることになるわけです。

iOS側の対応

ViewControllerに適当にUIWebViewを置いてあり、ViewControllerViewControllerの遷移をするSegueがnextという名前で定義されているとします(実際にはViewControllerからViewControllerへのSegueは定義できないので、隠しボタンからViewControllerへのSegueです。)。


#define HOST @"http://localhost:4000"

- (void)viewDidLoad {
    [super viewDidLoad];

    self.webView.delegate = self;
    NSString* urlstring = NULL;
    NSString* path = self.segueOptions.stringValue;
    if(path) {
        urlstring = [NSString stringWithFormat:@"%@%@", HOST, path];
    } else {
        urlstring = [NSString stringWithFormat:@"%@%@", HOST, @"/"];
    }

    NSURL* url = [NSURL URLWithString:urlstring];
    NSURLRequest* urlRequest = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:urlRequest];
}

肝心のコールバックはこんな感じで実装しておきます。

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    if ([[[request URL] scheme] isEqualToString:@"callback"]) {
        NSString* href = [[[request URL] absoluteString] substringFromIndex:11];
        [self performSegueWithIdentifier:@"next" options:href];
        return NO;
    }
    return YES;
}

shouldStartLoadWithRequestは新しいリクエストが来た時に通るコールバックで、何もしないときはYESを返すことで読み込みをスタートさせることができるわけですが、今回はcallback://で始まるURLの場合だけはnextという名前のSegueを実行してNOを返すことにします。NOを返せばURLの読み込みは行われないので、callback://****みたいなURLを処理しちゃってエラー、ということも起こりません。

これで最初に紹介したGIFアニメのようなページ遷移ができるようになりました。便利なような、使いどころ難しいようなそんな感じですが、ネイティブっぽく動かすという目的はなんとなく果たせていますね。

参考リンク

reportBackToIOSのところはStackOverflowのこの質問を参考にしました。

ios - How can I reliably detect a link click in UIWebView? - Stack Overflow

ボタン以外(UILabelとか)をタップしたいなら透明ボタンがいいかも

iOS開発でボタン以外の要素をタップさせたくなることがたまにあるかもしれない。例えばラベルとか画像とかね。

そういった場合によく取られる方法はもしかしたらtouchesBegantouchesEndedのイベントを使うことかもしれない。けれど、タッチ系のイベントで自分で何とかしようとするとタップしたあと領域外で離したときとかその逆の動きとかで微妙に挙動がうまくいかないので操作感も悪くなってしまう。

やはりボタン系の操作はUIButtonが最強なのでここはUIButtonを透明にしてしまって、触らせたい要素の上におけばいいじゃないか。

例えばUIImageViewをタップさせたいならこういう↓ふうにまったく同じ大きさのボタンを作って、ボタンの文字をなくして背景色を透明にしてしまう(デフォルト透明)。

ボタンのほうが上にある感じ。

あとは普通にUIButtonのTouchUpInsideを受け取ればOK。あたかも画像をタップしているようにみえる。

Segueによる画面遷移を便利にするTKRSegueOptions

4月ぐらいにスライドで見ていいなーと思ったんだけど、最近初めて使ってみてやはり便利だったので整理しておく。

Storyboardでの画面遷移をスマートにやる方法 - TOKOROM BLOGStoryboardでの画面遷移をスマートにやる方法 - TOKOROM BLOGはてなブックマーク - Storyboardでの画面遷移をスマートにやる方法 - TOKOROM BLOG

余談だけどスライドでみたときはTKRIntentって名前だったんだけど、わかりやすさのために名前変えたんですかね。

どんなときに便利かっていうと、個人的にはテーブルビューでセルを選択して次の画面に行きたいときに、選択したセルに関連する情報とかを次のViewControllerに渡したいときかなと思う。

TKRSegueOptionsを使わないときは、ViewControllerを生成してidを渡して画面遷移するっていうやり方をしてた気がする。もっとスマートな方法があったかもしれないが。

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    MyViewController* viewController = [[MyViewController alloc] initWithNibName:@"MyViewController" bundle:nil];
    viewController.index = indexPath.row;
    [self.navigationController pushViewController:viewController animated:YES];
}

これがTKRSegueOptionsを使うとこんな感じでいける。

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    [self performSegueWithIdentifier:@"Design" options:@{@"id":[NSNumber numberWithInteger:indexPath.row]}];
}

画面遷移自体はStoryboardでくっつけたSegueをIdentifier経由で呼べばよくて、移動先でself.segueOptions[@"index"];みたいに取り出すことができる。

これSDK標準でやって欲しいぐらい便利。使わない手はない。