タマネギプログラマーの雑記

たまねぎ剣士的なニュアンス

iOSでPIN入力とかSMS入力とかどう作ろうかという話

本記事はRecruit Engineers Advent Calendar 2019 - Adventar 15日目の記事です。

アプリをいじってるとまま遭遇する桁数指定の入力画面。
標準のViewだとこういうViewは提供されていない。
では、どうやって実現すればいいのかなぁというのをちょっとやってみる。

これは完全に予防線なんだけど、やってみてるだけなのでベストプラクティスとは基本言えないです。
多分より良いやり方は存在するはずなので、ご存知の方がいたら是非教えていただきたいです。
あくまで、「俺の考えたView」なのであしからず。

PIN入力

一番簡単なのは、OSのキーボードを使わせず、入力ボタンを自前で用意してやることだと思う。
まず、大雑把にUIStackViewを並べる。
f:id:gaoxin-xixxix:20191207172619p:plain

次に、入力桁数を示す「●」を上部のUIStackViewに突っ込む

override func viewDidLoad() {
    super.viewDidLoad()

    for _ in 1...disit {
        let indicator = UILabel.circle
        inputIndicators.append(indicator)
        upperStackView.addArrangedSubview(indicator)
    }
}

ちなみに、UILabel.circle はこんな感じで定義した雑なViewである。

fileprivate extension UILabel {
    static var circle: UILabel {
        let uilabel = UILabel()
        uilabel.text = "●"
        uilabel.font =.systemFont(ofSize: 60)
        uilabel.textColor = .gray
        uilabel.textAlignment = .center
        return uilabel
    }
}

これで、割と等間隔でそれっぽい見た目が簡単に作れる f:id:gaoxin-xixxix:20191207213753p:plain

下部には各数字とデリートのキーを配置していく

        let firstLow = makeLow(from: 1, to: 3)
        let secondLow = makeLow(from: 4, to: 6)
        let thirdLow = makeLow(from: 7, to: 9)
        
        let fourthLow = UIStackView.horizontal
        let zeroPad = NumPadLikeUILabel(of: 0)
        zeroPad.registerTap(target: self, action: #selector(numPadDidTapped(_:)))
        let deleteButton = UILabel()
        deleteButton.text = "←"
        deleteButton.bottunify()
        
        let deleteTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(deleteDidTapped))
        deleteTapRecognizer.delegate = self
        deleteButton.addGestureRecognizer(deleteTapRecognizer)
        [zeroPad, deleteButton].forEach { fourthLow.addArrangedSubview($0) }
        
        [firstLow, secondLow, thirdLow, fourthLow].forEach { lowerStackView.addArrangedSubview($0) }

UIGestureRecognizer は、各インスタンスで一つのViewとしか紐付けられないため、各Viewごとにインスタンスを作ってやる必要がある。 そのため、.registerTap というファンクションを実装してそれぞれのViewに自分で作らせるようにすると楽。

NumPadLikeUILabel はタップアクションと数字の紐付けを明確にするため、UILabelを拡張子、そのUILabelが担う数字を保持できるようにしたものである。
こうすることで、ViewController側で各Viewがどの数字のボタンなのかを管理する必要がなくなる。

fileprivate class NumPadLikeUILabel: UILabel {
    let number: Int
    init(of number: Int) {
        self.number = number
        super.init(frame: .zero)
        text = String(number)
        bottunify()
    }

    func registerTap(target: UIGestureRecognizerDelegate, action: Selector) {
        let gestureRecognizer = UITapGestureRecognizer(target: target, action: action)
        gestureRecognizer.cancelsTouchesInView = false
        gestureRecognizer.delegate = target
        addGestureRecognizer(gestureRecognizer)
    }
}

あとは、各タップのアクションごとに入力された数字を取得、それに合わせて●のViewの色を変えてやれば意図したUIが出来上がる。

   @objc func numPadDidTapped(_ sender: UITapGestureRecognizer) {
        guard let pad = sender.view as? NumPadLikeUILabel else {
            return
        }
        if number.count >= digit {
            return
        }
        
        number.append(pad.number)
        syncViewAndNum()
        
        if number.count == digit {
            guard let nextVC = UIStoryboard.init(name: "SMSStoryboard", bundle: nil).instantiateInitialViewController() else {
                return
            }
            present(nextVC, animated: true, completion: nil)
        }
    }
    
    @objc func deleteDidTapped() {
        number.removeLast()
        syncViewAndNum()
     }

f:id:gaoxin-xixxix:20191207192740p:plain
ただ、このやり方はコピーアンドペーストや、oneTimeCode などのオートフィルに対応していないという欠点がある。
オートフィルに対応するためには TextInput プロトコルに準拠しなければならないためだ。
一方、この TextInput プロトコルはなかなか準拠するために実装しなければならない物が多く、あまり自分でやりたくない。
なので、既存のViewを利用しつつ、コピペやオートフィルに対応したViewを作ってみる。

コピペ対応SMS入力View

まず、UIStackView と、その上に UITextField を置いたViewを作る。
f:id:gaoxin-xixxix:20191207221306p:plain
指定した桁数分の、数字を表示するラベルを UIStackView に突っ込ませるようにする。
また、キーボードの設定も numberPad にしてやる。 textContentTypeoneTimeCode にすると、認証用コードのテキストフィールドであることを明示できる。

    func configure(with digit: Int) {
        self.digit = digit
        for _ in 1...digit {
            let (labelView, label) = codeLabel()
            codeLabels.append(label)
            stack.addArrangedSubview(labelView)
        }
    }
    
    private func setup() {
        Bundle.main.loadNibNamed("SMSView", owner: self, options: nil)
        addSubview(contentView)
        contentView.frame = self.bounds
        contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        virtualTextField.tintColor = .clear
        virtualTextField.keyboardType = .numberPad
        virtualTextField.textContentType = .oneTimeCode
        virtualTextField.becomeFirstResponder()
        virtualTextField.delegate = self
        virtualTextField.borderStyle = .none
        virtualTextField.backgroundColor = .clear
    }

    private func codeLabel() -> (UIView, UILabel) {
        let container = UIView()
        let label = UILabel()
        let bottomLine = UIView()
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 40)
        
        container.addSubview(label)
        container.addSubview(bottomLine)
        
        label.translatesAutoresizingMaskIntoConstraints = false
        label.leftAnchor.constraint(equalToSystemSpacingAfter: container.leftAnchor, multiplier: 0).isActive = true
        label.rightAnchor.constraint(equalToSystemSpacingAfter: container.rightAnchor, multiplier: 0).isActive = true
        label.centerYAnchor.constraint(equalTo: container.centerYAnchor).isActive = true
        label.heightAnchor.constraint(equalToConstant: 50).isActive = true
        
        bottomLine.backgroundColor = .lightGray
        bottomLine.translatesAutoresizingMaskIntoConstraints = false
        bottomLine.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 10).isActive = true
        bottomLine.rightAnchor.constraint(equalTo: container.rightAnchor, constant: -10).isActive = true
        bottomLine.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 5).isActive = true
        bottomLine.heightAnchor.constraint(equalToConstant: 2.0).isActive = true
        return (container, label)
    }

これでそれっぽい見た目が出来上がる。
f:id:gaoxin-xixxix:20191207224618p:plain
あとは、キーボードの入力と見た目を同期させてやる処理を書く。
UITextFieldDelegatefunc textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) でキーボードの入力を受け取れるため、

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        if string.isEmpty && !codes.isEmpty {
            codes.removeLast()
            syncView()
            return true
        }
        if codes.count >= digit {
            return false
        }
        if string.count > 1 {
            for char in string.compactMap({ $0 }) {
                guard let i = Int(String(char)), codes.count < digit else {
                    return true
                }
                codes.append(i)
            }
            
        } else {
            guard let code = Int(string) else {
                return false
            }
            codes.append(code)
        }
        syncView()
        return true
    }

このようにロジックを書く。
return値で true を返すとstringの文字が UITextField に反映され、falseを押すと無視される。
ちなみに、デリートキーを押したときには string に空文字が入る。最上部はその処理である。
指定桁数を超えた場合は無視する処理を書き、
コピペやオートフィルの際の処理のため、 string.count > 1 の内部処理を書く。
最後に、Viewと同期させる syncView を呼ぶ。
syncView の中身はこうなっている。

    private func syncView() {
        for (i, label) in codeLabels.enumerated() {
            if codes.count <= i || codes.count == 0 {
                label.text = nil
                return
            }
            label.text = String(codes[i])
        }
        virtualTextField.text = codes.map {String($0)}.joined()
    }

こうすることで、上部のラベルに入力内容が反映される。
f:id:gaoxin-xixxix:20191207224631p:plain
これでコピペにも対応したSMS入力画面ができた!

f:id:gaoxin-xixxix:20191207224709p:plain
コピペにも対応

なお、本当はオートフィルにも対応できてるはずなのだが、なぜか出てこない……。
これについては課題とさせていただきたい。
前に組んだときはできていたので、今回できないのが謎。 ただ、前回組んだときは一番親のViewを UIKeyInput に準拠させた方法でやっていた気がするので、なんかその辺りにヒントがあるかもしれない。

サンプルコードはこちら github.com

Vaporで作る簡単webサービス 〜ビューもあるよ!〜

この記事はRecruit Engineers Advent Calendar 2018の記事です。

Swiftといえば、iOSアプリケーションを作成するための言語、というイメージが強いと思うが、実際はiOSとは独立した言語である。
(ちょっと嘘。実質多くの部分iOSのエコシステムにロックインされてる感は否めない)
事実、Swift製のコマンドラインツールなども作られており、様々な用途に(頑張れば)使うことができる。
そこで今回は、サーバーサイドSwiftのフレームワークであるVaporで、簡単なウェブサービスを作ってみたいと思う。

vapor.codes

VaporはKituraなどと並び、最近どこかで話題のSwift製Webフレームワークである。
基本的にはRailsフォローのよくあるフレームワークの体裁に感じる。
Swift4.0, XCode10.1 で実装した。

今回実装したコードはこちら

github.com

導入

まず、vaporをインストールする。

brew install vapor/tap/vapor

プロジェクトの作成は

vapor new <project name>

で行うことができる。
また、このとき --template オプションで、どの種類のテンプレートを用いるか指定できる。
今回はweb オプションを用いてプロジェクトを作成してみる。
実際に発行したコマンドは
vapor new VaporWebExample --template=web
である。

f:id:gaoxin-xixxix:20181218000643p:plain
vapor new後の表示
これを実行すると、こんな感じでファイルが生成される。
f:id:gaoxin-xixxix:20181210221728p:plain
生成直後の様子

普段xcodeを使っている人なら、見慣れた ~.xcodeproj が存在しないことに気づくだろう。
なので、xcodeで開発を進めたい場合、これを生成する必要がある。
vapor xcode コマンドを使うと生成される。
なんで標準でついてないんや・・・って思ったけど、XCodeVaporが採用してるテンプレート言語であるLeafに対応していないため、XCode以外を使うっていう選択肢が割とあるんじゃないかと思う。

何はともあれ、骨組みはできたので実装に入っていく。

今回は、やってみた系の定番である、Todoサービスを作っていこうと思う。
最低限ではあるが、以下の機能を実装する。

  • Todo一覧表示
  • Todoの追加
  • Todoの削除

ルーティングの設定

まずは、get/todos のルーティングを追加していく。
vaporにおいて、ルーティングの設定は、Routes.swift に記述する。
プロジェクト生成初期には以下のように記述されており、get /get/hello/:parameter が設定されている。

public func routes(_ router: Router) throws {
    // "It works" page
    router.get { req in
        return try req.view().render("welcome")
    }
    
    // Says hello
    router.get("hello", String.parameter) { req -> Future<View> in
        return try req.view().render("hello", [
            "name": req.parameters.next(String.self)
        ])
    }
}

とりあえず、ここに get todos を足してみよう。

router.get("todos") { req -> Future<View> in
        return try req.view().render("hello", [
            "name": "todo"
        ])
}

こんな感じで。ビューをまだ用意してないので、hello のビューを拝借する。
ビューに対して渡すパラメータはString "todo" に固定する。
これで get todos を行えば、Hello, todos と表示されるはずである。
XCode上からrunし、localhost:8080/todos にアクセスすると

f:id:gaoxin-xixxix:20181210225630p:plain
Hello, todo!

ちゃんと出た。

DBの設定

次に、DBを使えるようにしていく。
今回は手軽にsqliteを使うこととする。

Vaporでデータベースを扱うときは vapor/fluent を使うと良いだろう。
Fluentは、Vapor公式が作ったORMフレームワークなので、簡単に導入することができる。
Vapor謹製のDBフレームワークとしては、DatabaseKit というというものもあるが、Fluentはこれをラップし、使いやすい形にしたものだとか。

Fluentを使用するために、依存管理にFluentを追加する。
VaporはSPMによる依存管理を行っているため、これに従う。
Package.swiftを以下のように記述する。
ついでにvapor/leaf (後述)も追加しておく。
Fluentは、mysqlpostgresql に対応しているが、それぞれに対応したFluentのライブラリがあるため、自分が使うものをしっかり入れる必要がある。(今回はFluentSQLite)

// swift-tools-version:4.0
import PackageDescription

let package = Package(
    name: "VaporWebExample",
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"),

        // 🍃 An expressive, performant, and extensible templating language built for Swift.
        .package(url: "https://github.com/vapor/leaf.git", from: "3.0.0"),
        .package(url: "https://github.com/vapor/fluent-sqlite.git", from: "3.0.0")
    ],
    targets: [
        .target(name: "App", dependencies: ["Leaf", "Vapor", "FluentSQLite"]),
        .target(name: "Run", dependencies: ["App"]),
        .testTarget(name: "AppTests", dependencies: ["App"])
    ]
)

今回のように、何か依存を追加した際は、xcodeprojファイルを更新するためにvapor xcode を実行する必要がある。

次に、Todo モデルを実装する。 クラスを作成し、SQLiteModelプロトコルに準拠させれば良い。
今回は以下のようにした。
初期生成されるディレクトリであるModels直下に配置する。

import FluentSQLite
import Vapor

final class Todo: SQLiteModel {
    var id: Int?
    var name: String
    var priority: Int?
    
    init(id: Int? = nil, name: String, priority: Int) {
        self.id = id
        self.name = name
        self.priority = priority
    }
}

extension Todo: SQLiteMigration {}
extension Todo: Content {}

ちなみにFluentのモデルを作る際、var id: Int? だけは必須で定義する必要がある。
SQLiteMigration プロトコルにも準拠させておくと、自動でマイグレーションを行ってくれるようになる。
また、後ほどフォームで送られてきたデータを、Todoにバインディングするために、Content プロトコルにも準拠させておく。

以下をconfigure.swiftに記述し、FluentProviderとマイグレーションを登録する。

// sqliteのセットアップ
try services.register(FluentSQLiteProvider())
let db = try SQLiteDatabase()
var dbconfig = DatabasesConfig()
dbconfig.add(database: db, as: .sqlite)
services.register(dbconfig)

// マイグレーションの登録
var migrations = MigrationConfig()
migrations.add(model: Todo.self, database: .sqlite)
services.register(migrations)

これで、とりあえずsqliteが使えるようになった。
SQLiteDatabase() の引数に何も渡さなかった場合、in memoryのSQLiteが使われるようになる。
ちなみに、今回は詳しくは触れないが、services はVaporが持つDIコンテナフレームワークである。
services.register() で依存を登録することができる。

コントローラの追加

次にコントローラを実装する。
まずは動作確認のために、固定でTodoをいくつかViewに渡し表示するようにする。

import Vapor

final class TodosController {
    func index(_ req: Request) throws -> EventLoopFuture<View> {
        let todos = [Todo(name: "a", priority: 1), Todo(name: "b", priority: 1), Todo(name: "c", priority: 1)]
        return try req.view().render("Todos/index", ["todos": todos])
    }
}

Controller上では、(Request) throws -> RequestEncodable となるfunctionを定義する必要がある。
この型で定義しないと、Routes.swift で登録することができない。

引数として渡されてくるreq: RequestはDIコンテナとしての側面を有しており、 req.make(SomeService.self) を行えば、services.register() で登録したサービスを取得することができる。
DBのコネクションなどもこのRequest から取得する方式となっている。
個人的にはリクエストという形でアプリケーションのコンテキストが渡ってくるのはどうなんだろうと思う。

req.view().render で、ビューの描画処理を行う。
引数として指定しているTodos/index は、描画に使うleafファイルのパスである(後述)。第二引数は、第一引数で指定したleafファイルにパラメータとして渡される。

ビューの追加

次に、前項で指定したビューを実装する。
Vaporでは、Leaf というテンプレートフレームワークを用いてビューを実装する。
残念なことに、XCodeシンタックスハイライトに対応していない。
頼みの綱のAppCodeもプラグインが完成しておらず(Javaに興味ないからごめんな!!と公式は言っている)、現状VSCodeのみがLeafに対応したエディタとなっている。
なので、諦めてXCodeVSCodeの両刀使いとなるか、我慢してシンタックスハイライトなしで開発をしよう。
僕は後者を選んだ。一応、leafファイルを選び、Editor > Syntax Coloring > HTML でHTMLシンタックスハイライトを設定すれば多少マシになる。
設定してて悲しい気持ちになってはくるが。

Leafの文法はSwiftにインスパイアされており(公式はそう言い張っている)、Swiftに慣れていれば使い勝手は良いらしい(僕は感じなかった)。
基本的にはHTMLをそのまま記述していき、要所要所でLeafタグと言われる要素を記述することで、動的なHTML生成を行うことができる。
詳しくはこちらを参照してほしい。
Overview - Vapor Docs

今回は以下のようなテンプレートを実装した。

#set("title") { Todos }

#set("body") {

<div class="container">
    <div class="page-header">
        <h1>Todos</h1>
    </div>
    <ul class="list-group">
        #for(todo in todos) {
        <li class="list-group-item">#(todo.id): #(todo.name)</li>
        }
    </ul>
</div>
}

#embed("base")

#embed() は別のleafファイルの呼び出しを行い、今回は以下の base.leaf を呼び出している。

<!DOCTYPE html>
<html>
<head>
   <title>#get(title)</title>
   <link rel="stylesheet" href="/styles/app.css">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
</head>
<body>
    #get(body)
</body>
</html>

#set() は、leafファイル上で使う変数を作成し、#get() で呼び出す。
これで、一応のコントローラとビューは実装できた。
これらをRoutes.swift で登録する。

public func routes(_ router: Router) throws {
    let todos = TodosController()
    router.get("/", use: todos.index)
}

コントローラの登録は簡単である。インスタンスを生成し、そのファンクションをrouter.get() の第二引数として渡せばいい。
第一引数にはパスを渡す。
なお、この get はhttpリクエストのGETを表すので、 router.postrouter.delete のように、他のhttpリクエストを定義することももちろん可能である。
この状態でアプリを起動し、/ にアクセスする。

f:id:gaoxin-xixxix:20181216223641p:plain

無事に、固定のTodoを表示することができた。

Modelの操作

次に、動的にTodoを変化させられるように、Todoを追加する機能を実装する。
まず、新しいTodoを投稿するための画面を作成する。
new.leaf という名前で以下のファイルを作成した。

#set("title") { Add Todo }

#set("body") {
<div class="container">
    <div class="page-header">
        <h1>Add Todo</h1>
    </div>
    <form action="/todos" method="post">
        <div class="form-group">
            <label for="exampleInputEmail1">What you have to do?</label>
            <input type="text" class="form-control" name="name" placeholder="input a todo">
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
</div>
}

#embed("base")

これを Routes.swift に追加する。
new.leafは、特に動的に変化する箇所がないので、コントローラに新しいファンクションを足すまでもないだろう。
その場合は以下のように記述することができる。

router.get("todos/new") { (req: Request) -> EventLoopFuture<View> in
        return try req.view().render("Todos/new")
 }

これで以下の画面が表示できるようになった。

f:id:gaoxin-xixxix:20181217001113p:plain
Todo登録画面

今度は、この画面から送られたフォームのデータから、新しい Todo をデータベースに保存する処理を記述する必要がある。
そのためにTodosControllerに、create ファンクションを追加する。

func create(_ req: Request) throws -> EventLoopFuture<Response> {
        return try req.content.decode(Todo.self).map(to: Response.self, { todo in
            Todo.query(on: req).create(todo)
            return req.redirect(to: "/", type: .normal)
        })
 }

formの値は、 Content プロトコルに準拠したstructかclassで受け取ることができる。
req.content.decode(Todo.self) で、上で Content プロトコルに準拠させておいたTodo を指定することで、そのプロパティ名と一致したformのデータをバインディングすることができる。
バインディングされた後は EventLoopFuture<Decodable> の形になる。
EventLoopFuture 型は、単純に Future と同様のものと考えて基本的には差し支えない。
そのため、結果に対する処理をコールバック内で定義する必要がある。
今回はTodoを追加した後にトップにリダイレクトさせようと思うので、新しいTodoを保存した後、リダイレクトを表すResponse 型に変換してやる。 こうすることによって、新しいTodo を保存した後、Todo一覧画面にリダイレクトさせることができる。
さらに、/todos へのPOSTをRoutes.swift へ登録することで、Todoの追加ができるようになる。

router.post("/todos", use: todos.create)

Todoの新規追加ができるようになったので、一覧画面も、ちゃんとTodoを読み込んで表示するように変更する。
TodoControllerのindexファンクションを以下のように変更する。

func index(_ req: Request) throws -> EventLoopFuture<View> {
        return try req.view().render("Todos/index", ["todos": Todo.query(on: req).all()])
}

Todo.query(on: req).all() で全てのTodoを取得することができる。

DELETEの実装

今度はTodoの削除を実装して実装については終わりにしたいと思う。
Routes.swift に以下を追加する。
DELETEなのにGETにしてたりするのはただの横着なので許していただきたい。

router.get("todo", Int.parameter, "delete", use: todos.destroy)

ここでは、第二引数に Int.parameter を指定している。
こうすることによって todo/:id/delete のようなパスを表現することができる。
コントローラの方は、追加と同様の要領で実装できる。

func destroy(_ req: Request) throws -> EventLoopFuture<Response> {
        return try Todo.find(req.parameters.next(Int.self), on: req).map(to: Response.self) { todo in
            todo?.delete(on: req)
             return req.redirect(to: "/", type: .normal)
        }
 }

一覧に削除ボタンを追加すれば、表示、追加、削除が完成する。

f:id:gaoxin-xixxix:20181217005111p:plain
完成形

公開

最後に、Vaporが用意している簡単な公開方法を紹介して終わりにしたいと思う。
VaporはVapor Cloudというホスティングサービスを提供している。
vapor.cloud こちらを利用すると、驚くほど簡単にVaporアプリケーションを公開することができる。
個人で試す程度なら無料で使えるので、是非一度触ってみていただきたい。
手順を以下に紹介する。

前提条件として、gitで管理されている必要があるので、もし行っていない場合はセットアップしなければならない。
もう一つ必要な準備として、cloud.yml というファイルを、プロジェクトルートに配置する必要がある。
今回、僕はSwift4.0で実装を行ったので、以下のような記述を行った。

type: "vapor"
swift_version: "4.0.0"

あとはCLIで以下の手順を行うだけである。

  1. vapor cloud signup を実行し、Vapor Cloudにサインアップする。
  2. vapor cloud login を実行し、ログインする。
  3. vapor cloud deploy を行う。

CLIからの指示に従い、情報を入れればデプロイが完了する。

と思ってたのだが、今回は何故か失敗した・・・
前回行ったときは問題なかったのだが、何がいけないんだろうか(調べるつもりはあまりない)。

まとめと感想

とりあえず、VaporによるTodoサービスを作成してみた。
触ってみた感じ、思ってたよりもまともに開発ができるなという気分だった。
RailsなどのWebフレームワークに触ったことがあれば、比較的簡単に習熟できるだろう。
各種設定も簡便で、心地よく実装することができる。
特にORMのFluentについては、ちょろっと使うという点についてはかなり楽に動かすことができた。
他にも、DIなどのWebフレームワーク定番の機能についても、多言語のフレームワークにあるものについては最低限備えられているように感じた。
また、Swiftの強力な言語機能をうまくつかっており、きちんと型設計をしながら運用すれば、安全に大規模なシステムを組むこともできるポテンシャルを感じた。

一方で、足りない箇所も多々あり、まだまだ実戦投入はしたくないという所感を持った。
特に、ビュー周りについては改善が望まれる。
テンプレートフレームワークLeaf周りは、エディタの支援を受けられないという点で非常にもどかしい。
また、ビューの生成を支援するようなhelpler的な機構が(少なくとも少し探した範囲では)見つからず、他ページへのリンクを書くに際しても、べた書きをせざるを得ないという体たらくである。
Leafのカスタムタグを使えば、helperのようなことはできるのだが、フレームワーク標準で用意されているタグが貧弱すぎる。
もっとも、カスタムタグを使ったとしても、バックエンドレイヤーとの連携を支援するような機構は無いため、やはり不便の感は否めない。
また、公式のドキュメントが不親切であることも痛感した。
今回この程度のサンプルを実装するだけでも、実装コードを読みに行ったり、サンプルコードを探し求めたりするなど、そこそこバカにならない時間がかかった。
サーバーサイドSwiftの普及を目指すならば、こういった点の改善が早急に求められる状況だと言えるだろう。

Swiftの言語機能から来る期待はやはり大きく、書いていて楽しいのは事実であり、趣味の開発には充分に堪えるものだと思う。
ビュー周りの不便さについても、APIを開発するのに使う分には問題にならないし。
ただし、足りない部分は依然多く、プロダクションで使えるようになるのはまだまだ先の話だろう。

iOS開発の自動テストについて最近思っていること

この記事はSwift愛好会 Advent Calendar 2017の19日目の記事です。

はじめに

僕は基本的に自動テストは書くべきであると考えている。
最近仕事で触れているコードが中々のレガシーコードであるから、なおさらだ。

レガシーコードに変更を加えるときには、意図せぬ副作用が生じ、プロダクトを壊してしまう可能性が常につきまとう。
そんな時にテストコードがあれば、自分が既存のロジックを意図せずに破壊してしまっていないか知ることができ、作業に対し安心を感じることができる。

残念ながら、僕が最近対峙しているレガシーコードには、充分なテストコードが用意されていなかった。
そのため、否応なしにiOSプロジェクトでのテストについて考えることが多くなってきた。
一方、僕は幸運にして、周りにテストについての知見者が多い。
そういう人たちに相談したりしながら、最近考えていることをとりとめなく書いていこうと思う。

iOSという環境とテストについて

僕は、iOSプロジェクトという環境は、比較的テストを書くモチベーションを下げやすい環境だと考えている。
その理由は大きく2つだ。

  • テストの実行に時間がかかる
  • UIテストなど、テストが書きづらい箇所が存在する

それぞれの理由についてもう少し掘り下げていこう

テストの実行に時間がかかる

XCodeでのテストの実行は、プロジェクトのビルドがはいるため、基本的に遅い。
その間の時間を潰すために、僕はデスクにハンドグリップとハンドスピナーを置いているくらいだ。

『レガシーコード改善ガイド』に、優れた単体テストの条件として次の2つが上げられている。

  • 実行が速い
  • 問題箇所の特定がしやすい

この条件を見ると、こう思うはずだ。 「iOSでの単体テストは、優れたテストになり得ないじゃないか」と。

『レガシーコード改善ガイド』には、またこうも書いてある。

実行に0.1秒もかかる単体テストは、遅い単体テストである。

つまり、XCode上で動く単体テストは、どうあがいても「遅い単体テスト」になってしまい、「優れたテスト」になりえないのである。
この時点で、僕はiOSプロジェクトにおける単体テストは、別環境における単体テストと比べ、相対的にコストパフォーマンスが低くなる宿命にあると考えてしまう。

UIテストなど、テストが書きづらい箇所が存在する

前提として、UIテストを書くことはコストが高い。
高い上に壊れやすい。
また、昨今のアプリケーションはデザインの変更が頻繁に行われるため、メンテナンスコストも非情に高くなる。
結果として、UIテストはコストパフォーマンスが悪い場合が多く、UIテストは書かない、という風になりがちだ

それならばそうで、別の箇所のテストを充分に書ければ良いのだが、iOSのプロジェクトでは概してViewとロジックが密に結合してしまっている。
これは、iOSで多く使われているアーキテクチャMVCであることに起因する(もしくはテストへの知識不足)。
MVCでは、ViewControllerがファットになりやすい傾向にあり、そこにロジックが多く混入してしまいやすいのだ。 その上、ViewControllerはViewからのデリゲートなどが多く、テストが非常に難しい。
結果として、テストで保護できないロジックが多く出てしまう。

ではiOSでテストを書くのは悪手なのか

上までで、iOS環境でのテストを少しdisったわけなのだが、では、iOSでテストを書くのはあまり価値がないことかというとそうではない。
ただ、iOSでのテストは、他環境とは違ったテストとの向き合いかたをしなければならないという風に僕は考えている。

まず、テストの実行が遅い点について考える。
テストの実行は、Carthageの使用など、ビルド時間を短くする工夫で多少ましになるが、それでも0.1秒以下は望めないだろう。
この時点で、iOSのテストは、実行コストが高いテストであることは間違いない しかし、今まで何気なく使っていた、「コスパ」という言葉に目を向ける必要がある。

自動テストにおけるコストは何か。
単純に考えるならば、テストの作成、メンテナンスにかかる労力、実行にかかる時間だろう。
ではパフォーマンスとは何か?
これは、人によっても違うだろうが、少なくとも僕の場合は、「作業時の心理的安全性」である。

そもそも、『レガシーコード改善ガイド』で、速いテストが良いとされているのは、コードの編集都度に、気軽にテストの実行ができるためである。
編集の都度テストを実行することができれば、自分の変更が問題ないかどうか確認しながら作業をすすめることができる。
これが、テストによる心理的安全性の確保である。

では、遅いテストでは何が問題になるのか。
高頻度に実行した際の作業オーバーヘッドが大きくなってしまうのである。

f:id:gaoxin-xixxix:20171218001941p:plain

ならば、長くかかる分、テストの実行頻度を減らして作業すれば、作業能率は下がらないはずである。 もっとも、その分自分の編集による破壊に気づくタイミングは平均して遅くなるが、全く破壊に気づかないことに比べれば遥かに旨味があるだろう。

ただ、その頻度をどれくらいに設定するかと言うのは難しい話で、今のところは、「かかる時間へのコスト間と手に入る安心感が釣り合う程度の頻度」という理解にとどまっている。

テストが書きづらい箇所についてはどうだろうか?
これについてはおそらく多くの人が答えを知っているはずで、MVVMやMVPなどのアーキテクチャを少しづつでも導入してくのが一つの回答になる。
テストしづらいViewControllerから、ViewModelやPresenterといった、ロジックを剥がし取る層を用意してやるのである。
MVCからアーキテクチャを変更できない特別な理由があるのなら、古式ゆかしい~Serviceや~Modelといった名前のモデルたちに可能な限りロジックを移していく。
テスト内にてインスタンスを生成できるオブジェクトは、一般的にテストがしやすい。
そのため、上記の方法で比較的簡単にテスト可能なロジックを増やすことができるはずだ。

また、UIテストについても、いわゆるスモークテストの範囲程度を用意してやる分には充分コストに見合うリターンがあると考えられる。
一般に主導線の大規模な変更は少ないし、書く範囲を限定すれば、その分コストも下がるからだ。

まとめ

結局のところ、テストが僕達にもたらしてくれるものは安心感である。
その安心感は何にも替えがたく、また、手戻り率などの面で定量的なリターンももたらしてくれるだろう。
しかしながらやはり、iOSでのテストとの付き合いというのは、まだまだ考えなければならない部分も多く、自分でもまだ納得のいく答えまでたどり着けていないと考えている。
なので、なにかしらご意見があればご教授いただければなあと思ったりしております。

フロー効率とリソース効率について思うこと

最近、フロー効率、リソース効率という言葉をよく聞くようになってきた。
業界でどの程度流行っているのかは知らないが、少なくとも@i2key御大将の近くのコミュニティに属している関係で、僕は日常的によく聞く。
リソース効率に偏りがちなシステム開発の環境において、フロー効率という考え方が広まっていることは単純に良いことだと思う。

一方で、フロー効率がバズワード的になるにつれて、正しく理解されていないのではないかと思える声もしばしば聞くようになった。
曰く、「リソース効率は悪でフロー効率こそが目指すべき姿」であるとか、「フロー効率こそが価値を最大限に発揮できる方法」であるとかそういう声である。
これはリソース効率というコンセプトの理解として正しくない。

もっとも、これは僕の観測範囲での話なので、実際そう勘違いしている人は少ないのかもしれない。
しかし、もしかしたら多数いるそういう人達へ向けて、そうでなくとも一回自分の考えをまとめる意味も込めて、フロー効率とリソース効率について文章を書いてみる。

但し書き

  • 僕は学生時代の知識から、工程管理的なアプローチでフロー効率とリソース効率について書いていく
  • 僕はリーンやXPの専門家ではない。「こういう解釈もある」程度に思うのがおすすめ
  • フロー効率を銀の弾丸だと思っている人をメインの対象にしている。そうでない人には、焼き直しになって退屈かもしれない
  • この記事は、Recruit Engineers Advent Calendar 201710日目の記事である

フロー効率とリソース効率

まず、フロー効率とリソース効率という言葉についておさらいしてみたいと思う。
これは正直、前述の@i2keyさんのこの記事を読んでしまうのが一番はやいと思う。

簡単に一言で表すならば

フロー効率: それぞれのアイテムが最も効率よく工程を進むことにフィーチャーすること
リソース効率: それぞれのリソースが最大限稼働することにフィーチャーすること

と言える。

とどのつまりこの二つは、何の効率に焦点を当てて考えるかというコンセプトの違いを表す言葉である。

フロー効率は正義なのか

上記の@i2keyさんの記事を読めば、「フロー効率はとても良いものだ」という印象を持つ人が多いだろう。
それ自体は問題ないのだが、「良いものだ」と思いすぎてしまう場合これはまずい。
この記事はとてもいい記事なのだが、バイアスがかかりやすい箇所が多少ある。
例えば、こちらの図などは誤解を生みやすい。

https://cdn-ak.f.st-hatena.com/images/fotolife/i/i2key/20170514/20170514231345.jpg 引用: (http://i2key.hateblo.jp/entry/2017/05/15/082655)

この図だけを見た人は必ずこう思うはずである。
「全てのアイテムが同じ期間で完了するのなら、トータルのリードタイムが短くなるフロー効率の方が良いに決まっているじゃないか」と。
しかし、実際には多くの場合で、フロー効率の方が全てのアイテムが完了するタイミングは遅くなる。
フロー効率側は大体においてオーバーヘッドが発生するため、一つ一つのアイテムが完了するのにかかる時間が長くなってしまうからだ。
@i2keyさんがこの記事の内容を口頭で説明するときには補足してくれるのだが、記事内では充分ではない。

フロー効率に集中するということは、一部のリソースを遊ばせてでもあるアイテムのリードタイムを短くするということである。
そのため、リソース効率に比べオーバーヘッドが発生しやすい。 具体的な例を下に示す。

完了に1単位時間かかる行程が3つずつあるアイテムA・Bを、作業者A・Bの二人で完了させる場合を考える。
ただし、アイテムAの工程2は、同アイテムの工程1が完了しない限り取り掛かれないという制約があるものとする。
フロー効率、リソース効率、それぞれのアプローチで工程を組むと以下のようになる。

f:id:gaoxin-xixxix:20171206231634p:plain
フロー効率時

f:id:gaoxin-xixxix:20171206231843p:plain
リソース効率時

これを見ると、フロー効率の場合、アイテムAのリードタイムは短くなるが、全てのアイテムの完了が遅くなることがわかる。

フロー効率では、個々のアイテムのリードタイムは減るが、フローを優先するために、発揮できる総生産量が低くなってしまうのである。
一方リソース効率は、個々のアイテムのリードタイムは増えるが、極力リソースを遊ばせないため、発揮できる総生産量が高くなる。

最も、アイテムAの工程間に制約がなければフロー効率でも3単位時間で全てのアイテムは完了するのだが、実際の開発においては多くの場合こういった制約がついて回るはずだ。

このように、基本的には、組織の総生産量の観点で有利なのはリソース効率の方である。

ではフロー効率はいけないのか

上で、組織の総生産量の文脈ではリソース効率の方が優れているという話をした。
しかし、だからと言ってフロー効率がいけないというわけではない。
この文章の主旨はフロー効率disではなく、それぞれの特性を理解し、正しく使うことが大事だということである。

もう一度それぞれの特性をまとめてみよう。

フロー効率: あるアイテムのリードタイムを短くすることができるが、総生産量の面で不利
リソース効率: 組織の総生産量の最大化に有利であるが、個々のアイテムのリードタイムの面で不利

こうして見ると、総生産量とリードタイムのコンテクストにおいては、きれいにトレードオフになることがわかる。
では、それぞれはどのような場合に適材なのだろうか。

フロー効率は、任意のアイテムのリードタイムを短くできる。
ということは、仮説検証型の開発にもってこいと言える。
一回のリリースサイクルのリードタイムを短くすることで、仮説検証の試行回数を増やすことができるからだ。
イテレーティブな開発では、完了しなければならないアイテムの総数は一般的に決まっていないため、総生産量はある程度犠牲にしても良い。

リソース効率は、フィードバックを入れて方向転換することには弱いが、予め決められたアイテム群をより早く完了することができる。
よって、いわゆるプロジェクト型の開発に向いていると言える。
プロジェクト型の開発では、プロジェクト完了までアイテムが価値を発揮する必要がないため、個々のリードタイムの長さは問題にならない。

じゃあGoogleとかはどうなの?

こういう話をすると、フロー効率擁護のニュアンスで 「それでもGoogleは、週の20%をフリーにすることで生産性を上げた」や、「稼働率100%のCPUはまともに動かない」といった意見を言ってくる人がたまにいるが、これは詭弁ないしは勘違いである。
というのも、上記2例は本質的にフロー効率とリソース効率とは違う文脈の話だからだ。

(僕が考えるに)フロー効率とリソース効率という言葉は、工程設計の戦略を表す言葉である。
工程設計とはある種の制約充足問題であって、個々のリソースの稼働率は一定であるという前提が必要である。
そのため、フロー効率的なアプローチでも、リソースである人間は制約上可能な限り最大限に稼働することが前提となる。
100%の稼働による人間の生産性の低下は、議論の埒外の話なのである。

それはどちらかというと、リソースとしての人間の特性の話であり、これは人間工学などの方が対象分野としては近いだろう。
そういった点は、フロー効率だろうとリソース効率だろうと、一日の工数を低めに設定することで実現を目指すことができる。

まとめ

リソース効率は銀の弾丸ではなく、利点と欠点を持った一つのアプローチにすぎない。
トレードオフを理解し、適材適所で使う事が大事だ。
もちろん、理想的な状況は、リードタイムが短くかつ総生産量も損ねない状況であるため、フロー効率的な工程でリソースが遊ばないような工程設計ができるのが一番良い。
しかし、現実には制約があり、そのような理想的な工程設計は多くの場合できない。
そのため、自分たちが置かれた状況を理解し、より良い手段を選択しなければならないのである。

WKWebViewのログイン保持

iOSでWebページを表示するために使用するWKWebView
今回、WKWebViewでのログイン保持でハマったためワークアラウンドを書いておく

今回想定するアプリ

f:id:gaoxin-xixxix:20171004234507p:plain

一度webviewに行った後、webview内でログインを行う。 その後もう一度viewに戻り、再度webviewを開く。
この時、ログインが保持されていないという現象にあった。

どうしてこうなる

どうもセッションcookieをWKWebViewがストアしておかないことによるものらしい。

nsmutableurlrequest - Losing cookies in WKWebView - Stack Overflow

iOS WKWebView Tips | Professional Programmer

必要なcookieを調べて、WKWebViewから抜いて直接NSHTTPCookieStorageに突っ込めたりしないかと考えたが、そもそもWKWebViewからセッションcookieを抜くことができないっぽい。

解決策

解決策というか、ワークアラウンドなのだが、なんならバッドノウハウかもしれないので、ご意見があったらうかがいたいのだが。 今回は、上記二つ目のリンクでも述べられているWKProcessPoolを活用することにした。

WKProcessPoolとは、複数のwebview間でWeb Content Processを共有するためのものだ。

WKProcessPool - WebKit | Apple Developer Documentation

これを活用すると、cookieが複数のwebviewで(確かめてないから本当かわからないが)リアルタイムに共有されるらしい。
が、リアルタイムというのは今回はあまり重要ではない。
今回重要なのはWeb Content Processが共有されるという点だ。
つまり、同じWKProcessPoolを使うwebview間であれば、その存在が時系列上ずれていたとしてもセッションcookieを共有できるはずと考えた。
なので

extension WKProcessPool {  
    static let shared = WKProcessPool() 
}  
  
class ViewController: UIViewController {  
    override func viewDidLoad() {  
        let configuration = WKWebViewConfiguration()  
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)  
#中略  
    }  
}  

というようにしてやると良い。
複数のViewControllerで使わないのであれば、WKProcessPoolはシングルトンっぽくしなくても、propertyとしてもってやればいいだろう。

ここで注意しなければいけないのは、WKWebViewConfigurationはWKWebViewのinitializerに渡してやらなければならないという点だ。

let webview = WKWebView()
webview.configuration.processPool = WKProcessPool.shared

のようにやった場合はうまくいかなかった。
今度時間がある時にWKWebViewのコードを呼んで理由を探そうと思う。

RxWebKitがいい感じだったよ

RxSwiftの関連ライブラリーを調べていたところRxWebKitなるものを発見。

github.com

要はWebKitのRxSwiftラッパーです。
いい感じにWKWebViewを扱えてとても楽しい! 結構素敵な感じに使えたので、シンプルなサンプルアプリを作ってみます。

導入

まずは普通に

pod 'RxWebKit'

ってな感じにしてpod install。 Carthageでもできますが、例のごとく割愛いたします。

普通にViewを構築

WKWebViewはコードで追加するので、タイトルやロードビュー、進む戻るボタンなどをテキトーに配置します。 f:id:gaoxin-xixxix:20170531215605p:plain

IBOutletでViewControllerに引っ張ってきます。

import UIKit
import WebKit

class ViewController: UIViewController {
    @IBOutlet weak var progressbar: UIProgressView!
    @IBOutlet weak var refreshButton: UIBarButtonItem!
    @IBOutlet weak var forwardButton: UIBarButtonItem!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var backButton: UIBarButtonItem!
    @IBOutlet weak var toolbar: UIToolbar!
    @IBOutlet weak var containerView: UIView!
}

次に、viewDidLoad内で、WKWebViewを追加します。 navigationDelegateはぶっちゃけ今回いりませんが書いちゃってます。

setUpConstraints(for webView: WKWebView )では、WKWebViewをcontainerViewにぴったり貼り付ける制約を加えています。 webview.loadで、githubをロードし始めます。

override func viewDidLoad() {
        super.viewDidLoad()
        
        // set up WKWebView
        let webview = WKWebView()
        webview.navigationDelegate = self
        webview.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(webview)
        setUpConstraints(for: webview)

        // load github.com
        let request = URLRequest(url: URL(string: "https://www.github.com")!)
        webview.load(request)
}

private func setUpConstraints(for webview: WKWebView) {
        webview.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 0.0).isActive = true
        webview.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0.0).isActive = true
        webview.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0.0).isActive = true
        webview.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0.0).isActive = true
}

これでViewパーツの配置は終わりです。

webviewのプロパティをバインディングしていく

ここからRxWebKitを活用する作業に入っていきます。

webview.rx

からWKWebViewに拡張されたRX系のプロパティにアクセスできます。

まずは、RxSwift、RxWebKitをimportします。

import RxWebKit
import RxSwift

そして、webviewのtitleをtitleLabelのtextにbindします。 こうすることで、webviewが遷移する度に表示されているページのタイトルがtitleLabelに自動的に反映されます。 driveを使うか迷ったのですが、どっちが良いんでしょうねこれ。

// bind title to title label
webview.rx.title.bind(to: titleLabel.rx.text).addDisposableTo(disposeBag)

次に、進む戻るボタンのenabledを制御します。
WKWebViewには、canGoForwardとcanGoBackというプロパティがあり、それぞれ、進めるページがあるか、戻れるページがあるかを取得できます。
丁度ブラウザの進む戻るボタンと同じです。
これもRX化されているので、こちらで用意した進む戻るボタンのisEnabledにbindします。(こちらはdriveを使いました)

ただし、UIBarButtonのisEnabledはRxのobserverが用意されていません。
なので、自分で実装することになります。
今回は、RxSwift公式のサンプルに倣い、BindingExtension.swiftというファイルを作って実装しました。

import UIKit
import RxSwift
import RxCocoa

extension Reactive where Base: UIBarButtonItem {
    var isEnabled: UIBindingObserver<Base, Bool> {
        return UIBindingObserver(UIElement: base) { button, isEnable in
            button.isEnabled = isEnable
        }
    }
}

こんな具合に、送られてきたBoolをUIBarButtonItemのisEnableに対応させるようなObserverを作ります。 これに、先程のcanGoForwardとcanGorBackをbindすることで、自動的にUIBarButtonItemが有効化、無効化するようになります。

// handle back and forward buttons availablity
webview.rx.canGoForward.asDriver(onErrorJustReturn: false).drive(forwardButton.rx.isEnabled).addDisposableTo(disposeBag)
webview.rx.canGoBack.asDriver(onErrorJustReturn: false).drive(backButton.rx.isEnabled).addDisposableTo(disposeBag)

実際にこれらがタップされたときの処理と、 リフレッシュボタンがタップされたときの処理は、RxWebKitの機能を用いていないので割愛します。

最後に、プログレスバーの処理を設定します。
RxWebKitではページの読み込みに関係するプロパティとして、loadingプロパティとestimatedProgressが存在します。
前者は現在読み込みを行っているかを表しており、
後者は読み込みがどの程度完了しているか推定値で表しています。

プログレスバーにはUIProgressViewを用い、loadingプロパティを表示非表示に、estimatedProgressを進行度合いにbindしました。 なお、UIProgressViewのisHiddenと進行度合いを表すprogressはRX対応されていないので、またも自作observerを実装します。

先程のBindingExtensionに下記を追加します。 isProgressingは進行中かどうかisHiddenは消えているかを表しているため、 notを入れることでちょうど進行中に表示されるようになります。

extension Reactive where Base: UIProgressView {
    var progress: UIBindingObserver<Base, Float> {
        return UIBindingObserver(UIElement: base) { progressbar, progressRate in
            progressbar.progress = progressRate
        }
    }
    
    var isProgressing: UIBindingObserver<Base, Bool> {
        return UIBindingObserver(UIElement: base) { progressbar, isProgressing in
            progressbar.isHidden = !isProgressing
        }
    }
}

これらに前述のプロパティをbindします。

コメントの下の行では、WKWebViewより前に来るように、bringSubViewで最前線に持ってきています。

// set up progressbar
containerView.bringSubview(toFront: progressbar)
webview.rx.loading.asDriver(onErrorJustReturn: false).drive(progressbar.rx.isProgressing).addDisposableTo(disposeBag)
webview.rx.estimatedProgress.map { progress in
     return Float(progress)
}.asDriver(onErrorJustReturn: 0.0).drive(progressbar.rx.progress).addDisposableTo(disposeBag)

ここで気をつけなければいけないのはestimatedProgressのbindです。 estimatedProgressはDouble型、UIProgressViewのprogressはFloat型なので、一度mapを噛ませるなりして型を揃えてやる必要があります。

以上で必要な処理は全てです。

結果

f:id:gaoxin-xixxix:20170531224656g:plain

すっきりとしたコードでこんな感じのwebviewが実装できちゃいます!素敵!!

今回のサンプルのコードです。

github.com

Moyaでお手軽API通信

ブログ始めてみたんですけど、特に抱負も何もないので、しれっとMoyaで遊んだ事について書きます。

Moyaって?

github.com

APIとか叩く時に作られがちな、"APIManager"とか"NetworkModel"みたいな、 ネットワークを抽象化するレイヤーの役割をしてくれるライブラリ。 内部的にはAlamofireを叩いていて、言うならAlamofireのラッパーとも言えるかもしれない(言えないかもしれない)。

何が便利?

公式が言うには

  • 正しいAPIのエンドポイントにアクセスしてるかコンパイル時にチェックできる
  • enumの連想値使って異なるAPIの使い方をちゃんと定義できる
  • testする時stub的な役割を果たせる

個人的に思ったのは

  • siestaとかよりhttp通信の層をよく隠してくれるので楽
  • 仕組みが割とわかりやすいので使う上でリーズナブル

といったところでしょうか。

使い方

導入

Podfileに

pod 'Moya'

を記入し、pod installで簡単にインストールできます。 なお、Moyaを入れるとAlamofireも一緒に入るので別途記述する必要はありません。 ちなみにSwift Package ManagerやCarthageでも導入可能です。

今回は、 github.com を使って基本的な使い方をなぞってみます。 MoyaのレポジトリにあるBasicUsageとほぼ同じ内容です。

enumを作成

Moyaでは、自分が使いたいAPIの情報をenumに持たせます。 まずは、それぞれのAPIターゲットをenumとして列挙します。 また、それぞれのターゲットに対し渡すパラメータがある場合は、連想値でこれを定義します。

import Moya

enum HackersNewsAPI {
    case item(id: Int)
    case user(id: String)
    case maxItem
    case topStories
    case newStories
}

また、このenumTargetTypeプロトコルに準拠する必要があります。 具体的には

  • var baseURL: URL
  • var path: String
  • var method: Moya.Method
  • var parameters: [String: Any]
  • var parameterEncoding: ParameterEncoding
  • var sampleData: Data
  • var task: Task

のプロパティを設定しなければなりません。 今回は以下のように準拠させます。

extension HackersNewsAPI: TargetType {
    // ベースURL
    var baseURL: URL { 
          return URL(string: "https://hacker-news.firebaseio.com/v0")! 
    }
    
    // それぞれのターゲットごとのpath
    var path: String {
        switch self {
        case .item(let id), .user(let id):
            return "/\(id).json"
        case .maxItem:
            return "/maxitem.json"
        case .newStories:
            return "/newstories.json"
        case .topStories:
            return "/topstories.json"
        }
    }
    
    // それぞれのターゲットごとのhttpメソッド
    // 今回はgetしかないが、postなどある場合はswitch selfなどで適切な値を返す
    var method: Moya.Method {
        switch  self {
        case .item, .user, .maxItem, .newStories, .topStories:
            return .get
        }
    }

    // 今回はパラメーターとして渡さないのですべてnil
    var parameters: [String: Any]? {
        return nil
    }
    
    // パラメーターのエンコーディング指定
    // リクエストボデイにjsonとしてセットすることもできる
    var parameterEncoding: ParameterEncoding {
        return URLEncoding.default
    }
    
    // テスト時に使われる(なんでマストなのかわからない)
    // めんどくさかったので今回はずるします
    var sampleData: Data {
        return Data()
    }
    
    var task: Task {
        switch  self {
        case .item, .user, .maxItem, .newStories, .topStories:
            return .request
        }
    }
}

以上でenumの準備は完了です。

ここまで書いて、題材にしたAPIがあんま良くなかったことに気づく。 が、面倒くさいので強行します。

MoyaProviderを使ってAPIリクエストする

enumの準備が整ったら、MoyaProviderを生成し、リクエストを送ることができます。

let provider = MoyaProvider<HackersNewsAPI>()
provider.request(.newStories) { result in
    
}

provider.requestの第二引数となっているクロージャが受け取っているresultは、 .success(Moya.Response).failure(MoyaError)を持つenumです。 なので基本的には

provider.request(.newStories) { result in
    switch result {
    case let .success(moyaResponse):
        do {
            try moyaResponse.filterSuccessfulStatusCodes()
            let data = try moyaResponse.mapJSON()
            // do something with the data
        }
        catch {
           // failed to convert to JSON
        }
    case let .failure(error):
           // failed at api access
    }
            
}

のようにしてエラー検知すると良いと思います。

filterSuccessfulStatusCodesは、ステータスが200~299でなければ例外を投げるメソッド。 mapJSONは名前の通りデータをJSONマッピングしてくれるメソッドです。

どちらもMoya.Responseに組み込まれたメソッドです。

まとめ

今回は本当に触りしかやりませんでしたが、Moyaは、APIリクエストの際に挟み込めるクロージャーや、 テストを意識した機能、RX対応など、まだまだ魅力的な機能を持っています。 特にRX対応は割とAPIもイケてると思うので是非触ってみてもらえたらなと思います。

一応今回のソース

github.com