Bitissues を Swift で実装し直した

春に RubyMotion で実装し直した Bitbucket のイシュー専用クライアント『Bitissues』だけど、 今度は Swift で実装し直した。

https://itunes.apple.com/jp/app/bitissues/id693561154?mt=8&at=10l8JW&ct=hatenablog

1 つのアプリを Objective-C、RubyMotion、Swift と 3 つの言語で実装したことになる。 今後 iOS アプリは Swift で開発することに決めたので、もう実装し直すことはないはず。 多分。

RubyMotion から Swift に移行した理由は、新しい言語に挑戦したいとか、静的型付+型推論がいいとか、 Xcode の支援が欲しいとか色々あるけど、一番の理由は RubyMotion のライセンス費用を捻出できないこと。 RubyMotion 自体に不満は無い。

DTCoreText で NSAttributedString を高速に描画

先日、HTML 文字列を NSAttributedString に変換する方法を書いた。

NSAttributedString を UILabel の attributedText にセットするだけで、 お手軽に HTML を表示できるけど、UILabel は描画が非常に遅い。 UIWebView と同じか、少しマシかも、ってくらいには待たされる。 記事のサマリー程度でこの遅さ、ちょっと使えない。

実用的な速度で NSAttributedString を描画するライブラリを探してみたところ、 DTCoreText というのが良さそうだった。

低レイヤー API の CoreText を使って、ビューに直接テキストを描画するライブラリ。

例えば、DTCoreText が提供する DTAttributedTextView を使うことで、

self.attributedTextView = DTAttributedTextView(frame: self.view.bounds)
self.view.addSubview(self.attributedTextView)

let html = "<h1><a href=\"http://tnakamura.hatenablog.com\">Title</a></h1>"
    + "<ul>"
    + "<li><strong>Item1</strong></li>"
    + "<li><i>Item2</i></li>"
    + "</ul>"
let data = html.dataUsingEncoding(
    NSUTF8StringEncoding, allowLossyConversion: true)
let attrStr = NSAttributedString(HTMLData: data, documentAttributes: nil)

self.attributedTextView.attributedString = attrStr

HTML を画面いっぱいに表示することができる。速度も気にならない。

f:id:griefworker:20141127065245p:plain

今回はコードで直接サブビューに追加したけど、DTAttributedTextView は UIScrollView を継承しているので、 Storyboard に UIScrollView を貼り付けてカスタムクラスを指定する方法でも使える。 AutoLayout を設定するなら、そちらの方法がいいかも。

HTML を NSAttributedString に変換する

HTML の文字列は

let html = "<h1><a href=\"http://tnakamura.hatenablog.com\">Title</a></h1>"
    + "<ul>"
    + "<li><strong>Item1</strong></li>"
    + "<li><i>Item2</i></li>"
    + "</ul>"
let data = html.dataUsingEncoding(
    NSUTF8StringEncoding, allowLossyConversion: true)
let attrStr = NSAttributedString(HTMLData: data, documentAttributes: nil)

という風に NSData を経由して NSAttributedString に変換できる。

こいつを NSAttributedString を表示できるコントロール、例えば UILabel に

self.label.attributedText = attrText

でセットすれば、UIWebView を使わずに HTML を表示することができる。

AFNetworking と ReactiveCocoa を組み合わせる

ReactiveCocoa を導入したからには使い倒す方針でやっていて、 AFNetworking を使った Web API 呼び出しは、次のようにして RACSignal 化している。

let signal = RACSignal.createSignal({ (subscriber) -> RACDisposable! in
    let manager = AFHTTPRequestOperationManager(baseURL:  baseURL)

    let operation = manager.GET(
        path,
        parameters: parameters,
        success: { (operation, response) -> Void in
            subscriber.sendNext(response)
            subscriber.sendCompleted()
        },
        failure: { (operation, error) -> Void in
            subscriber.sendError(error)
        }
    )

    return RACDisposable(block: {
        operation.cancel()
    })
)
return signal

POST や PUT、DELETE も同じ。 たいして難しいコードじゃないけど、RACSignal 化するコードを何度も書くのはDRY じゃないよね。

実は、AFNetworking の拡張があったりする。

こいつを使えば、先ほどのコードを次のように書き変えることができる。

let manager = AFHTTPRequestOperationManager(baseURL:  baseURL)
let signal = manager.rac_GET(path, parameters: parameters)
return signal

成功・失敗のコールバックを引数で渡す必要がなくなって、見た目が非常にシンプル。

Swift で ReactiveCocoa を使う

はじめに

SwiftiOS アプリを実装し直すにあたって、 ただ Swift に翻訳するだけではつまらないので、 MVVM アーキテクチャで実装することに決めた。

View/ViewController と ViewModel のバインド、 イベントや非同期 API 呼び出しのインタフェースを統一するために、 ReactiveCocoa を導入する。

CocoaPods で RactiveCocoa をインストール

Podfile に

pod "ReactiveCocoa"

を追加して pod install

Bridging-Header で ReactiveCocoa のヘッダーファイルをインポート

<プロジェクト名>-Bridging-Header.h に次の行を追加。

#import <ReactiveCocoa/ReactiveCocoa.h>

これで Swift から ReactiveCocoa が使えるようになった。

RACSignal を作成してみる

API 非同期呼び出しのインタフェースを RACSignal で統一したいので、RACSignal でラップする。

class CommentViewModel: NSObject {
    var ownerName: String!
    var repoName: String!
    var issueId: Int!
    var comment: String?

    func postComment() -> RACSignal {
        return RACSignal.createSignal({ (subscriber) -> RACDisposable! in
            let manager = AFHTTPRequestOperationManager.manager()
            let operation = manager.POST(
                "/repositories/¥(self.ownerName)/¥(self.repoName)/issues/¥(self.issueId)/comments",
                parameters: ["comment": self.comment],
                success: { (operation, response) -> Void in
                    subscriber.sendNext(response)
                    subscriber.sendCompleted()
                },
                failure: { (operation, error) -> Void in
                    subscriber.sendError(error)
                }
            )
            return RACDisposable(block: { () -> Void in
                operation.cancel()
            })
        })
    }
}

UITextView に ViewModel をバインドしてみる

UITextView の text に変更があったら即座に ViewModel に反映させてみる。 Objective-C では RAC マクロを使ってバインドできるけど、Swift では RAC マクロが使えないので、 低レイヤーのメソッドを使うしかなかった。

self.commentTextView.rac_textSignal().setKeyPath("comment", onObject: self.viewModel)

ちなみに、同じことが UITextField でも可能。

ViewModel のプロパティを UIBarButtonItem にバインドしてみる

例えば、「ViewModel の comment プロパティが空ではないとき UIBarButtonItem を有効にする」 というのを ReactiveCocoa で実装するとこんな感じ。

self.viewModel.rac_valuesForKeyPath(
    "comment",
    observer: self.viewModel
).map({ (next) -> AnyObject! in
    let newComment = next as String
    return !newComment.isEmpty
}).setKeyPath("enabled", onObject: self.postButtonItem)

RACCommand を使って ViewModel のメソッドも UIBarButtonItem にバインドしてみる

「ViewModel の comment プロパティが空ではないとき UIBarButtonItem を有効にする」だけでなく、 UIBarButtonItem をタップしたときのアクションも併せて ReactiveCocoa で実装。

let commentSignal = self.viewModel.rac_valuesForKeyPath(
    "comment",
    observer: self.viewModel
).map({ (next) -> AnyObject! in
    let newComment = next as String
    return !newComment.isEmpty
})

let postCommand = RACCommand(
    enabled: commentSignal,
    signalBlock: { (input) -> RACSignal! in
        return self.viewModel.postComment()
    }
)

self.postButtonItem.rac_command = postCommand

ReactiveCocoa を使ってみて

インタフェースを RACSignal に統一できるので、コードを書きやすい。 特に RACSignal 化した非同期呼び出しは、引数でコールバックを渡す必要がないのでスッキリする。

ただ、コントロールにバインドする RACSignal や RACCommand は、 ViewModel のプロパティとして実装したほうが良かった。

ReactiveCocoa 難しい。 RACSignal にようやく慣れてきたけど、 RACCommand やその他の機能はまだ理解が追いついていない。

Swift で AFNetworking を使う

CocoaPods で AFNetworking をインストール

Podfile に

pod "AFNetworking"

を追加して pod install 実行。

Bridging-Header で AFNetworking のヘッダーをインポート

<プロジェクト名>-Bridging-Header.h に

#import <AFNetworking/AFNetworking.h>
#import <AFNetworking/UIImageView+AFNetworking.h>

を追加。

画像の非同期ダウンロード機能も使いたいので、 AFNetworking.h だけでなく UIImageView+AFNetworking.h もインポートしている。

AFHTTPRequestOperationManger を使ってみる

func loadRepositories() {
    let manager = self.createManager()
    manager.GET(
        "user/repositories/",
        parameters: nil,
        success: { (operation, json) -> Void in
            let array = json as NSArray

            // 配列を使ってなにかやる
        },
        failure: { (operation, error) -> Void in
            // エラー処理
        }
    )
    return nil
}

private func createManager() -> AFHTTPRequestOperationManager {
    let manager = AFHTTPRequestOperationManager(baseURL: self.baseUrl)

    manager.requestSerializer = AFHTTPRequestSerializer()

    // ユーザー名とパスワードを設定
    manager.requestSerializer.setAuthorizationHeaderFieldWithUsername(
        self.username!, password: self.password!)

    return manager
}

画像を非同期にダウンロードして表示してみる

UIImageView+AFNetworking.h をインポートすると、 AFNetworking が提供する画像の非同期ダウンロードが使える。

cell.imageView.setImageWithURL(
    NSURL(string: imageUrl),
    placeholderImage: userMenuImage
)

UITableView 編集時に挿入用の行を表示したい

UITableView 編集時のみ、挿入用の行を表示したい。こんなやつ。

f:id:griefworker:20140513201354p:plain

アイコン自体は tableView:editingStyleForRowAtIndexPath で UITableViewCellEditingStyleInsert を返せば表示できる。 ただ、編集時のみ挿入用の行を表示するとなると、結構面倒だった。

以下、RubyMotion でのサンプルコード。

# coding: utf-8

class AppDelegate
  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds)
    @window.rootViewController = MasterViewController.controllerWithNavigation
    @window.makeKeyAndVisible
    true
  end
end

class MasterViewController < UITableViewController
  CELL_ID = "cell-id"

  def self.controllerWithNavigation
    UINavigationController.alloc.initWithRootViewController(
      self.alloc.initWithStyle(UITableViewStylePlain)
    )
  end

  def items
    @items ||= [
      { title: "foo" },
      { title: "bar" },
    ]
  end

  def viewDidLoad
    super
    self.title = "Sample"
    self.navigationItem.rightBarButtonItem = self.editButtonItem
  end

  def tableView(tableView, numberOfRowsInSection:section)
    if self.isEditing
      # 編集中は追加行を表示するので行数 +1 する
      self.items.size + 1
    else
      self.items.size
    end
  end

  def tableView(tableView, cellForRowAtIndexPath:indexPath)
    cell = tableView.dequeueReusableCellWithIdentifier(CELL_ID) ||
      UITableViewCell.alloc.initWithStyle(UITableViewCellStyleDefault, reuseIdentifier: CELL_ID)
    
    if self.isEditing and
      indexPath.row >= self.items.size
      # 追加行のとき
      cell.textLabel.text = "追加"
    else
      item = self.items[indexPath.row]
      cell.textLabel.text = item[:title]
    end

    cell
  end

  def setEditing(editing, animated:animated)
    super

    # 追加行の IndexPath を求める
    row = self.items.size
    indexPath = NSIndexPath.indexPathForRow(row, inSection:0)

    # 追加行の挿入/削除
    self.tableView.beginUpdates
    if editing
      self.tableView.insertRowsAtIndexPaths(
        [indexPath],
        withRowAnimation:UITableViewRowAnimationFade
      )
    else
      self.tableView.deleteRowsAtIndexPaths(
        [indexPath],
        withRowAnimation:UITableViewRowAnimationFade
      )
    end
    self.tableView.endUpdates
  end

  def tableView(tableView, didSelectRowAtIndexPath:indexPath)
    if self.isEditing
      if indexPath.row < self.items.size
        # 編集ページに移動
      else
        # [追加] をタップしたので追加ページに移動
      end
    else
      # 詳細を表示
    end
  end

  def tableView(tableView, editingStyleForRowAtIndexPath:indexPath)
    if indexPath.row < self.items.size
      UITableViewCellEditingStyleDelete
    else
      # [追加] 行には + アイコンを表示
      UITableViewCellEditingStyleInsert
    end
  end
end

UITableView のプロパティ一発で出来るようになったらいいんだけど。