Swift+XcodeでiOSアプリを作ってみた(応用編)

/web/iphone

iPhoneアプリ作ってみたメモ。良くあるTableView + WebViewのニュース系アプリ作った過程での気付きなど。ビックボリュームになってしまったので、分割してみた2つめ。1つめの導入編もヨロシク!

  1. Pull to Refresh
  2. 無限スクロール
  3. アラートのモーダルを出す
  4. 通信エラーのアラートを出す
  5. WebView内のwindow.alert()でURLが邪魔な問題
  6. 閲覧中のタブをもう1回タップしたら初期化する
  7. Core Dataでデータを保存する
  8. CoreDataでスレッド処理を書く
  9. 参考書籍など

Pull to Refresh

var refresher = UIRefreshControl()

override func viewDidLoad() {
    super.viewDidLoad()
    
    // refresh
    //  - Swift 2.0: action: "refresh:",  ← 引数付きの関数を呼ぶときは、コロンを付ける
    //  - Swift 2.0: .addTarget(self, action: "refresh", ...
    //  - Swift 2.2: .addTarget(self, action: #selector(LatestEntryController.refresh), ...
    refresher = UIRefreshControl()
    refresher.attributedTitle = NSAttributedString(string: "引っ張って更新")
    refresher.addTarget(self, action: #selector(MasterViewController.refresh), forControlEvents: UIControlEvents.ValueChanged)
    self.tableView.addSubview(refresher)

    // first loading
    self.refresh()
}

いわゆる「引っ張って更新」する機能。UIRefreshControlに更新関数を紐付けて、tableViewに追加する。actionの取り方はSwift2.2でselector()を使うように変更された。今までちょっと特殊だったのでコレは良いね。

無限スクロール

tableViewをスクロールして末尾が近づくと、続くリストを逐次読み込んでいく機能。

// MARK: - Infinite Scroll

var nowLoading: Bool = false
var footerHeight: CGFloat = 0
var footerIndicator: UIActivityIndicatorView!

// viewForFooterInSection
override func tableView(tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
    footerIndicator = UIActivityIndicatorView(activityIndicatorStyle: .WhiteLarge)
    footerIndicator.color = UIColor(red: 90.0/255, green: 90.0/255, blue: 90.0/255, alpha: 1.0)
    footerIndicator.hidesWhenStopped = true
    footerIndicator.startAnimating()
    self.tableView.tableFooterView = footerIndicator
    
    footerHeight = footerIndicator.bounds.height * 3
    
    let footerView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: footerHeight))
    return footerView
}

// ignite loading
override func scrollViewDidScroll(scrollView: UIScrollView) {
    
    if scrollView.contentSize.height <= footerHeight || nowLoading {
        return
    }
    
    let currentOffset = scrollView.contentOffset.y
    let maximumOffset = scrollView.contentSize.height - scrollView.frame.size.height
    let distanceFromBottom = maximumOffset - currentOffset
    
    if distanceFromBottom <= footerHeight {
        if !scrollView.dragging {
            self.loadData()
        }
    }
}
  1. tableViewのフッターにUIActivityIndicatorViewを追加する。
  2. scrollViewDidScrollでスクロール終了後にローディング判定を行うこととする。
  3. nowLoadingの真偽値を設定し、ローディング中の多重起動を防ぐ。
  4. スクロール量とかコンテンツサイズとか諸々の計算をして、追加読込の関数(loadData())を発動する。

追加読込のやり方は、リストの実装方法によって変わる。現リストを削除してページ切替したり、CoreDataに新データ突っ込んで反映させたり。

アラートのモーダルを出す

iOS8環境から、UIAlertView/UIActionSheetが非推奨になりました。 なので、アラートのモーダルを出すにはUIAlertControllerを使います。

let alert:UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .Alert)
alert.addAction(cancelAction)
alert.message = "報告ありがとうございました!"

let cancelAction = UIAlertAction(title: "OK", style: .Cancel, handler: nil)
presentViewController(alert, animated: true, completion: nil)

通常のAlertかActionSheetかは、第三引数のpreferredStyleで指定する。


let alert = UIAlertController(title: nil, message: nil, preferredStyle: .ActionSheet)
let cancelAction = UIAlertAction(title: "戻る", style: .Cancel, handler: nil)
let agreeAction = UIAlertAction(
    title: "利用規約", style: .Default, handler:{(action:UIAlertAction!) -> Void in
        self.movePage(NSURL(string: "http://example.com/agree/")!)
    }
)
let manualAction = UIAlertAction(
    title: "使い方", style: .Default, handler:{(action:UIAlertAction!) -> Void in
        self.movePage(NSURL(string: "http://example.com/about/")!)
    }
)
let contactAction = UIAlertAction(
    title: "お問い合わせ", style: .Default, handler:{(action:UIAlertAction!) -> Void in
        let subString = String("アプリからのお問い合わせ")
        let escapedSubject = subString.stringByAddingPercentEncodingWithAllowedCharacters(.URLHostAllowedCharacterSet())
        UIApplication.sharedApplication().openURL(NSURL(string: "mailto:example@gmail.com?subject=\(escapedSubject!)")!)
    }
)

alert.addAction(cancelAction)
alert.addAction(agreeAction)
alert.addAction(manualAction)
alert.addAction(contactAction)
presentViewController(alert, animated: true, completion: nil)

UIAlertActionでボタンを定義し、addActionで増やしていく感じ。 handlerにクロージャを書くことが可能で、ボタンをタップした際の操作をここに記述できる。 ただしCancelボタンは特殊で、1つしか指定できません。

通信エラーのアラートを出す

let url = NSURL(string: "http://example.com/")!
let session = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
let task = session.dataTaskWithURL(url!, completionHandler: { (data, responce, error) in
    if error != nil {
        let alertController = UIAlertController(
            title: "Error: " + String(error!.code),
            message: error!.localizedDescription,
            preferredStyle: .Alert
        )
        let defaultAction = UIAlertAction(title: "OK", style: .Default, handler: { (action:UIAlertAction!) -> Void in
            self.refresher.endRefreshing() // end loading...
            self.nowLoading = false
        })
        alertController.addAction(defaultAction)
        presentViewController(alertController, animated: true, completion: nil)
    }
    else {
        // success!
    }
})
task.resume()

最初は単純に「通信に失敗しました」くらいでイイかなーと思ってたんですが、 エラーコード出したほうが報告合ったときにデバッグしやすいですし、localizedDescriptionだと日本語も出るので、まあいいかなと。

WebView内のwindow.alert()でURLが邪魔な問題

WebView内のJavaScriptでalert()やconfirm()を出していると、実際にポップアップするのはネイティブのAlertControllerとなる。 それだけなら良いんだけど、このAlertControllerにtitleが設定されていて、これがURLだったりすると違和感がすごい。

対策としてJavaScriptで生成したiframe内の空のdocumentからconfirm()を呼び出す方法を見つけましたが、 $.ajaxから呼ぶとアプリ側のdidFinishLoadが呼ばれなくてローディングのインディケーターが止まらなかった。 なので、document.locationで適当なスキーマをでちあげて、それをshouldStartLoadWithRequestにてフックする方法を取りました。

func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {

    // document.location = "report-api://hogehoge";
    if(request.URL!.scheme == "report-api"){
        let requestString = request.URL!.absoluteString
        let urlSplit = requestString.componentsSeparatedByString("//")
        let alert:UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .Alert)
        let cancelAction = UIAlertAction(title: "OK", style: .Cancel, handler: nil)
        alert.addAction(cancelAction)
        
        if(urlSplit[1] == "success"){
            alert.message = "報告ありがとうございました!"
        }
        if(urlSplit[1] == "failed"){
            alert.message = "処理に失敗しました。時間を置いて、再度お試しください。"
        }
        if(urlSplit[1] == "error"){
            alert.message = "通信に失敗しました。時間を置いて、再度お試しください。"
        }
        
        presentViewController(alert, animated: true, completion: nil)
        
        return false; // ロードしない
    }
    
    return true // 通常のhttp通信は許可
}

アプリ側で検知したら、titleをnilとしたAlertControllerを立ち上げてやれば問題ない。

閲覧中のタブをもう1回タップしたら初期化する

ドリルダウンでもWebViewでも、タブ切替の後にタブ内で遷移するUIだと、最初のページに戻りたくなる。 Twitterアプリとか大抵のアプリにはその機能がある。タブを連打すると、タブの初期ページに戻る機能ね。

import UIKit

class TabBarController: UITabBarController, UITabBarControllerDelegate {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.delegate = self
        self.selectedIndex = 0;
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    // タブ切替の前
    func tabBarController(tabBarController: UITabBarController, shouldSelectViewController viewController: UIViewController) -> Bool {
        // Select one controller in tabbar twice
        if  self.selectedViewController == viewController {
            // ex. TabBarController -> NavigationController -> ViewController
            if viewController.childViewControllers[0] is TabBarDelegate {
                let v = viewController.childViewControllers[0] as! TabBarDelegate
                v.didSelectTab(self)
            }
            
            return false
        }
        
        return true
    }
}

UITabBarControllerのshouldSelectViewControllerが、タブ切替が実行する前に発火するイベント。 ここで現在のViewControllerと移動先のViewControllerを比較すれば、2度押しの検知は可能。 俺はUITabBarControllerを継承するサブクラスを作っていて、汎用的な処理はすべてサブクラスに纏めているので、 このサブクラスからViewController内の初期化関数を発火させたい場合は、もう一手間必要になる。

import Foundation

protocol TabBarDelegate:class {
    func didSelectTab(tabBarController: TabBarController)
}

TabBarDelegate.swift作って、TabBarController(サブクラス)からViewController内のdidSelectTab関数を呼び出すDelegateを書いておく。(※Delegate実装はまだよくわかってません)

class MyPageController: UIViewController,UIWebViewDelegate,TabBarDelegate {
    @IBOutlet weak var webView: UIWebView!

    // タブを再度押すとリセット
    func didSelectTab(tabBarController: TabBarController) {
        self.configureView()
    }

    // WebViewの描画処理
    func configureView() {
        if let postWebview = self.webView {
            let requestURL = NSURL(string: "http://example.com/")
            let req = NSURLRequest(URL: requestURL!)
            postWebview.loadRequest(req)
        }
    }

    // 初期化
    override func viewDidLoad() {
        super.viewDidLoad()
        webView.delegate = self
        self.configureView()
    }
}

ViewController に TabBarDelegate を継承しておいて、肝心のdidSelectTabを書いておく。didSelectTab内で初期化関数を呼び出せば完了。 これでTabBarController --(TabBarDelegate)--> ViewControllerの通信が成り立つ。

Core Dataでデータを保存する

Core DataはAppleが提供しているデータの永続保存フレームワークで、変更管理できたり、RDBMSみたく関係性を定義できたり、複雑なクエリで検索できたりします。 テーブル定義をXcodeのGUIでできるところもポイント。 ただ、ドキュメントの割と最初の方に「Core Dataは関係データベースでも関係データベース管理システム(RDBMS)でもありません。」と書いてある。 内部的にはSQLiteを使ってるみたいですが。

// prepare
let appDel: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
let context: NSManagedObjectContext = appDel.managedObjectContext

// save data
var newUser = NSEntityDescription.insertNewObjectForEntityForName("Users", inManagedObjectContext: context)
newUser.setValue("sigwyg", forKey: "username")
newUser.setValue("pass123", forKey: "password")

// contextにアクセスするには、エラー処理が必要
do { try context.save() } catch { print("Cause Error") }

// fetch data
// returnsObjectsAsFaults: 検索に失敗した際にオブジェクトを返すかどうか。通常はfalse。
// predicate: エンティティを検索するときに条件を付ける
let request = NSFetchRequest(entityName: "Users")
request.returnsObjectsAsFaults = false 
request.predicate = NSPredicate(format: "username = %@", "sigwyg")

do {
    let results = try context.executeFetchRequest(request)
    if results.count > 0 {
        for result in results as! [NSManagedObject] {
            print(result.valueForKey("username")!) // Optionalになっているので、!でアンラップする
            print(result.valueForKey("password")!)

            // valueForKeyの結果はAnyObjectを返すので、混乱を避けるためStringにダウンキャストする
            if let username: String = result.valueForKey("username") as? String {
                print(username)
                context.deleteObject(result as! NSManagedObject)
                do { try context.save() } catch { print("delete failed") }
            }
        }
    }
} catch { print("Fetch Failed!") }

実装は上記のようにやる。プロジェクトを作成するときに「Use Core Data」にチェックを入れるとAppDelegate.swiftに追加されるコード、を削除していないことが条件。 このコードを手動で追加するなら、「Use Core Data」にチェックしなくても何時でも使える。

Note: 「Use Core Data」にチェックを入れるとAppDelegate.swiftに追加されるコード、はCoreData実装に必要であるが、AppDelegate.swiftに置く必要は無い。AppDelegateの肥大は好ましくないので、CoreDataStackみたいな適当なclassを作って、適宜読み込む感じのが何かと巧く行く。

Pinメニューにて上左右の制約を設定している様子

運用するデータモデルの変更は、通常はGUIで行う。 「Use Core Data」にチェックしていれば、[プロジェクト名].xcdatamodeldのファイルが作成されている筈です。MySQL脳からするとEntity = table, Attribute = fieldの単語変換で違和感なく追加できる。 CoreDataは端末に保存されているので、アプリケーション実装後にデータモデルが変更された場合、当然エラーになる。その場合は端末からアプリを削除して入れ直すか、マイグレーションを行う。マイグレーションに関しては割愛。まだそこまでやってない。

注意点として、NSManagedObjectはスレッド毎に用意する必要があり、何も考えずに利用しようとすると並列処理でバグってクラッシュしたりする。 要するにマルチスレッド処理は自分で書かなくてはいけない。 DBがデカくなるとメインスレッドがロックしちゃうことも増えてきたりとか、学習コストが高い。 本格的なDB運用を前提とするなら、RealmなんかのBaaSとかを検討したほうが良さげ。

FetchedresultscontrollerとTableViewの組み合わせとか強力なんで、ちょっとした機能ならコード量減って良い感じなんですけどネ。

参照: Core Data Programming Guide, iOS Developer Library

CoreDataでスレッド処理を書く

  • NSManagedObjectContextはThread-Safeではない
  • NSManagedObjectContextはスレッド毎に作る必要がある
  • NSManagedObjectContextの初期化時にMain/PrivateいずれかのQueueで処理するか設定することができる

以上を踏まえて、まず以下のコード

lazy var managedObjectContext: NSManagedObjectContext = {
    let coordinator = self.persistentStoreCoordinator
    var managedObjectContext = NSManagedObjectContext(concurrencyType: .MainQueueConcurrencyType)
    managedObjectContext.persistentStoreCoordinator = coordinator
    return managedObjectContext
}()

「Use Core Data」で作成したプロジェクトならAppDelegateでNSManagedObjectContextを初期化している筈なので、そこでconcurrencyTypeを指定してメインスレッドで実行させるようにできる。 concurrencyTypeのデフォルトはConfinementConcurrencyTypeだが、これは後方互換性の為だそうで、推奨されていない。 一般的には、MainQueueConcurrencyType(メインスレッドで実行)かPrivateQueueConcurrencyType(専用スレッドで実行)かを選べとある。

You use this queue type for contexts linked to controllers and UI objects that are required to be used only on the main thread.

- NSManagedObjectContext Class Reference

MainQueueかPrivateQueueかは似たような挙動を示すものの、UIオブジェクトやコントローラーに絡む場合はメインスレッドを使え、ということらしい。 あ、concurrencyで並列て意味らしいです。

dispatch_async(dispatch_get_main_queue(), { () -> Void in
    self.refresher.endRefreshing() // end loading...
    self.nowLoading = false
    self.tableView.reloadData()
})

他にキューやスレッド指定する方法としてはdispatch_async()やdispatch_sync()で囲ってやる方法がありますが、NSManagedObjectContextの場合はperformBlockとかperformBlockAndWaitを使用した方が安心。

let context: NSManagedObjectContext = fetchedResultsController.managedObjectContext
context.performBlockAndWait { () -> Void in
    let request = NSFetchRequest(entityName: "CurrentItem")
    request.returnsObjectsAsFaults = false
    do {
        let results = try context.executeFetchRequest(request)
        
        if results.count > 0 {
            for result in results {
                context.deleteObject(result as! NSManagedObject)
            }
            self.coreDataStack.saveContext()
        }
    } catch { }
}

performBlock で指定したブロックは Contextが管理するスレッド上で実行されることを保証されます。 NSManagedObjectContextはスレッド毎に作成するべきなので、処理を実行するスレッドごとにcontextを作成してperfomBlockで区切れば、マルチスレッド処理を実装できます。

  • performBlockやperformBlockAndWaitは、処理が特定のスレッドでのみ実行されることを保証してくれる
  • performBlockやperformBlockAndWait内でのオブジェクトへのアクセスはThread-Safeである
  • performBlockは非同期処理。performBlockAndWaitは同期処理(処理の終了を待つ)
  • NSManagedObjectContext間でのデータのやりとりはObjectIDを使用するが、データをマージするにはparentContext等で親子関係にしておくと便利

CoreDataを普通に書いていると、意図しないスレッド処理でスタックしたりするので、ビルド走らせる際にEdit Schemeで-com.apple.CoreData.ConcurrencyDebug 1を指定しておくと並列処理違反時に例外が発生して教えてくれます。正直これないと厳しい。 ( 参照:Core Data Concurrency Debugging )

参考書籍など

前述したようにCoreDataは学習コストが高いです。 初めてiOSアプリを作るに当たってハマり所の80%以上がCoreData関連でしたが、試行錯誤の過程で多くの知見を呼んでくれました。 Swiftにサクッと慣れることができたのはCoreDataを採用したおかげでしょう。 中でも初級コピペエンジニアからのブレイクスルーをもたらしてくれたのは、Realmのこの記事。

Let's Play: 巨大ビューコントローラをリファクタリングしよう!

やーリファクタリングは良いものです。書籍だと↓とか。

SwiftではじめるUI設計&プログラミング
小難しいけど、いくらか書いた後で読むと理解が進むように思います。
Swift+Core DataによるiOSアプリプログラミング
iOS8なので、そのまま書いても使えないコードもあるけど、図説が多くて判りやすいUIパーツの解説書。Xcodeの使い方とかも詳しくて、これは解りやすかったです。

応用編はここまで。導入編もヨロシク!

Note: スパム対策が面倒なので、コメント投稿を廃止しました。以前のコメントは残します。
ご意見・ご要望はtwitter@sigwygかはてブコメントにて。