読者です 読者をやめる 読者になる 読者になる

A Day In The Life

とあるプログラマの備忘録

6年前に開発したObjcのアプリをSwiftで書き直してみた

swift ios appstore

約6年前に LinkedWord という英英辞書アプリを開発しました。リリースからしばらく(2年ぐらい、時期的には iOS4とか5の時代)は機能アップデートやバグ修正をしていたのですが、本の執筆やら他のアプリの開発やらで忙しくなりしばらく放置してました。その間に iPhoneは5系や6系が発売され画面サイズが増えたり、iOS7で UI が刷新されたり、とわりと大きな変化がありました。本の執筆も落ち着いたし Swift を使ってアプリを1本ちゃんと開発したいと思ったので過去に開発した LinkedWord を作り直してみることにしました。以下やったことをつらつらと上げていきます。
from Objc to Swift

LinkedWord の概要

LinkedWord は単語の意味と単語にまつわるイメージを同時に検索できる英英辞書アプリです。他の辞書と違って、単語の意味とイメージが同時に検索できるので理解がしやすいのが特徴です。
linkedword image
また、単語の意味と同時にシソーラス(類義語)も表示してくれます。単語の意味を読んでもピンとこないときは類義語を見て理解を深めることができます。
LinkedWord 英英辞典 & 連想類語(無料)

使用している Web サービスの再検討(検討の結果変更なし)

LinkedWord は辞書検索と画像検索に以下の Web サービスを使用してました。

再開発にあたってもっと良い Web サービスがないかいろいろ探してみましたが、特に代わりになるようなものがなかったので現行のまま行くことにしました。できればレスポンスが JSON 形式のものが良かったのですがアプリの要件にあう良いものが見つかりませんでした。

XML Parser の再検討(検討の結果変更なし)

先ほどの Web サービスは両方ともレスポンスが XML 形式なので XML のパースをするライブラリが必要になります。6年前に開発した時は iOSXML をパースする場合は NSXMLParser 一択だったので SAX めんどくさいと思いながらも使ってました。6年たったしもしかしたら状況変わってるかもと期待しましたが未だに iOSXML をパースするには NSXMLParser 以外良い選択肢がなかったのでこちらも変わらず使用することにしました。

HTTP通信周りの実装(使用クラス変更)

Objc 版 LinkedWord では Web サービスとの通信は NSOperation + NSURLConnection でしたが Swift 版では NSOperation + NSURLSession( iOS7 から追加されたクラス) を使って実装し直しました。NSURLConnection と NSURLSession の使い方を比較すると以下のようになります。

NSURLConnection

let url = NSURL(string: "https://hogehoge.com")
let request = NSURLRequest(URL: url!)
NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.currentQueue()) {
  (response: NSURLResponse?, data: NSData?, error: NSError?) in
    // 通信完了後の処理...
}
// この処理がないと非同期でちゃんと動かない
while (isFinished) {
  NSRunLoop.currentRunLoop().runMode(NSDefaultRunLoopMode,
      beforeDate: NSDate(timeIntervalSinceNow: 0.1))
}

NSURLSession

let url = NSURL(string: "https://hogehoge.com")
let config = NSURLSessionConfiguration.defaultSessionConfiguration()
let session = NSURLSession(configuration: config,
    delegate: nil,
    delegateQueue: NSOperationQueue.currentQueue())
let request = NSMutableURLRequest(URL: url!)
let task = session.session.dataTaskWithRequest(request) {
  (data : NSData?, response : NSURLResponse?, error : NSError?) in
    // 通信完了後の処理...
}
task.resume()

そんなに変わらないといえば変わらないのですが、単純に新しい方がいいだろってことで NSURLSession クラスを使ってます。

使用する UI 部品の再検討(大幅に変更)

Objc 版の LinkedWord は以下の図のように画像を表示する部分と単語の意味を表示する部分に UIWebView を使用してました。
Old
Swift 版は UIWebView の使用をやめて画像を表示する部分に UICollectionView 単語の意味を表示する部分に UITextView を使用しました。図にすると以下のような感じです。
New

Xib から Storyboard へ

Xib で作成していた UI 定義を StoryBoard(Xcode4.2から導入された機能) に移植しました。画面遷移に関してコードをほとんど書く必要がなくなりかなり生産性がアップしました。

多端末対応(iPad 含む)

Objc 版 LinkedWord は iPhone3GS から iPhone4S の時代に開発していたものなのでそのころに比べて iPhone の種類も増え(5系、6系)、複数端末の画面サイズに対応する必要がありました。StoryBoard のおかげで生産性が上がった反面、多端末対応が必要になり以前に比べて UI 構築の難易度が上がったような気がします。AutoLayout(iOS6から) は使い始めたころかなり苦戦しましたし未だにコードで AutoLayout 書くのは苦手です。AutoLayout は大変ですがそのおかげで iPad への対応がかなり楽になりました。画面の表示に関して iPhone6S Plus に対応するのも iPad に対応するのも基本は変わらない感じで実装できます。iPad 対応に関してはセグエがいい感じになってて、モーダル表示の場合「Present Modally」から「Present As Popover」に変更するだけで iPhone の時はモーダル表示、iPad の時はポップオーバー表示になるなど痒いところに手が届く感じでとても簡単に対応できました。

NSUserDefaults から Core Data へ

ブックマークのデータをを古いバージョンでは NSUserDefaults に保存していましたが Core Data に移行しました。

iCloud 対応

Core Data に保存しているブックマークのデータを iCloud と同期するようにしました。Core Data を iCloud と同期させるには以下のようにコードを修正します。

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  :
  : 省略
  :
  lazy var persistentStoreCoordinator: NSPersistentStoreCoordinator = {
    let coordinator = NSPersistentStoreCoordinator(managedObjectModel: self.managedObjectModel)
    let url = self.applicationDocumentsDirectory.URLByAppendingPathComponent("LinkedWord.sqlite")    
    // iCloud用の設定
    let options: [String: AnyObject] = [
      NSPersistentStoreUbiquitousContentNameKey: "LinkedWord",
      NSMigratePersistentStoresAutomaticallyOption: true,
      NSInferMappingModelAutomaticallyOption: true
    ]
    var failureReason = "There was an error creating or loading the application's saved data."
    do {
      try coordinator.addPersistentStoreWithType(NSSQLiteStoreType, configuration: nil, URL: url, options: options)
    } catch {
      // Report any error we got.
      var dict = [String: AnyObject]()
      dict[NSLocalizedDescriptionKey] = "Failed to initialize the application's saved data"
      dict[NSLocalizedFailureReasonErrorKey] = failureReason     
      dict[NSUnderlyingErrorKey] = error as NSError
      let wrappedError = NSError(domain: "YOUR_ERROR_DOMAIN", code: 9999, userInfo: dict)
      NSLog("Unresolved error \(wrappedError), \(wrappedError.userInfo)")
      abort()
    }    
    self.registerCoordinatorForStoreNotifications(coordinator)    
    return coordinator
  }()
  lazy var managedObjectContext: NSManagedObjectContext = {
    let coordinator = self.persistentStoreCoordinator
    var managedObjectContext = NSManagedObjectContext(concurrencyType: .MainQueueConcurrencyType)
    managedObjectContext.persistentStoreCoordinator = coordinator    
    return managedObjectContext
  }()

  // MARK: - iCloud
  // This handles the updates to the data via iCLoud updates    
  func registerCoordinatorForStoreNotifications(coordinator: NSPersistentStoreCoordinator) {
    let center = NSNotificationCenter.defaultCenter()
    center.addObserver(self,
      selector: "storesWillChange:",
      name: NSPersistentStoreCoordinatorStoresWillChangeNotification,
      object: coordinator)
    center.addObserver(self,
      selector: "storesDidChange:",
      name: NSPersistentStoreCoordinatorStoresDidChangeNotification,
      object: coordinator)
    center.addObserver(self,
      selector: "persistentStoreDidImportUbiquitousContentChanges:",
      name: NSPersistentStoreDidImportUbiquitousContentChangesNotification,
      object: coordinator)
  }
  func storesWillChange(notification: NSNotification!) {
    print("storesWillChange:\(NSThread.isMainThread())")
    weak var moc = self.managedObjectContext
    moc!.performBlock {
      if moc!.hasChanges {
        self.saveContext()
      }
      moc!.reset()
    }
  }  
  func storesDidChange(notification: NSNotification!) {
    print("storesDidChange:\(NSThread.isMainThread())")
    // メインスレッドで実行
    let queue = NSOperationQueue.mainQueue()
    queue.addOperationWithBlock({
      // UIの更新用の通知
      let center = NSNotificationCenter.defaultCenter()
      center.postNotificationName("RefetchAllDatabaseData", object: self)
    })
  }  
  func persistentStoreDidImportUbiquitousContentChanges(notification: NSNotification!) {
    print("persistentStoreDidImportUbiquitousContentChanges:\(NSThread.isMainThread())")
    weak var moc = self.managedObjectContext
    moc!.performBlock {
      moc!.mergeChangesFromContextDidSaveNotification(notification)
    }
  }
}

手を抜いて対応してなかった機能を実装する

5年前に開発したときはめんどくさくて実装しなかった単語の自動補完と検索した単語が見つからなかったときのサジェスト機能を実装しました。

単語の自動補完

単語リストをJsonで作成して UISearchController を使って表示しています(膨大な数の単語リストをどうやって作成したかは秘密です)。UITableViewController を継承した SearchResultsViewController クラスを作成してそこに単語のリストを表示させる処理を実装します。

class SearchResultsViewController: UITableViewController {
  let searchCandidates: [String]
  var searchResults = [String]()
  required init!(coder aDecoder: NSCoder) {
    self.searchCandidates = ({
      // JSON
      let path : String = NSBundle.mainBundle().pathForResource("search_word_list", ofType: "json")!
      if let fileHandle : NSFileHandle = NSFileHandle(forReadingAtPath: path) {
        let data : NSData = fileHandle.readDataToEndOfFile()
        do {
          let json: NSArray = try NSJSONSerialization.JSONObjectWithData(data, options: NSJSONReadingOptions.MutableLeaves) as! NSArray
          return json as! [String]
        } catch {
          return [String]()
        }
      }
      return [String]()
    })()
    super.init(coder: aDecoder)
  }
  override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return self.searchResults.count
  }    
  override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath:indexPath) 
    cell.textLabel!.text = self.searchResults[indexPath.row]
    return cell
  }    
  override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    // 単語を選択したときの処理...
  }
}

UISearchController クラスを使用するクラス(ViewController という名前にしています)で UISearchController 型のプロパティの宣言と各種デリゲートメソッドを実装します。

class ViewController: UIViewController, UISearchResultsUpdating, UISearchBarDelegate {
  var searchController: UISearchController = UISearchController()
  override func viewDidLoad() {
    super.viewDidLoad()
    self.searchController = ({
      let searchController = self.storyboard!.instantiateViewControllerWithIdentifier("SearchResultsViewController") as! SearchResultsViewController
      let controller = UISearchController(searchResultsController: searchController)
      controller.hidesNavigationBarDuringPresentation = false
      controller.dimsBackgroundDuringPresentation = false
      let frame = controller.searchBar.frame
      controller.searchBar.frame = CGRectMake(frame.origin.x,
        frame.origin.y, frame.size.width, 44.0)
      controller.searchBar.showsCancelButton = true
      controller.searchBar.sizeToFit()
      controller.searchBar.autocapitalizationType = UITextAutocapitalizationType.None
      controller.searchBar.keyboardType = UIKeyboardType.WebSearch
      controller.searchBar.delegate = self
      controller.searchResultsUpdater = self
      return controller
    })()
  }
  // UISearchBar の内容が変わるたびに呼ばれるメソッド
  func updateSearchResultsForSearchController(searchController: UISearchController) {
    let searchResultTableViewController = searchController.searchResultsController as! SearchResultsViewController
    let searchText = searchController.searchBar.text
    searchResultTableViewController.searchResults = searchResultTableViewController.searchCandidates.filter() {
      if searchText == "" {
        return true
      }
      return $0.hasPrefix(searchText!)
    }
    searchResultTableViewController.tableView.reloadData()
  }
}

このコードがどんな感じで動いているかは LinkedWord をダウンロードして確認してみてください。

サジェスト機能追加

UITextChecker クラスを使うと単語のサジェストを実装することができます。

// スペルミスした単語
let word = "boak"
// サジェスト
let checker = UITextChecker()
let length = word.characters.count
let range = NSMakeRange(0, length)
let misspelledRange = checker.rangeOfMisspelledWordInString(word,
  range: range,
  startingAt: range.location,
  wrap: true,
  language: "en_US")
let suggests = checker.guessesForWordRange(misspelledRange, inString: word, language: "en_US")
// サジェクトしてくれた単語をコンロールログに書き出す
print(suggests)

アイコンを描き直し

アプリのリニューアルに向けてアイコンも心機一転、新しくしてみようということで書き直しました。以前のアイコンは製作に Inkscape を使用しましたが今回は Sketch3 を使いました。Sketch3 は1年半ほど前から使っています。アイコン描いたり図を作成するときに便利です。
アイコンの変遷

Game Center を導入したらリジェクトされました

辞書の検索回数を他のユーザと競ったり、検索回数やブックマークの数でアチーブメントを獲得できるように GameCenter を導入してみましたが、残念ながらゲーム以外で Game Center を使うなと言われリジェクトされました。設計や実装に結構な時間をかけたのでショックでした。勉強だったり教育みたいなものと Game Center は相性良いと思うんですけどねぇ。

価格の変更と広告の導入

リリース時はTier1(120円相当)で売っていましたが、インストール数が落ちてからはTier2(240円相当)に値上げしてました。今回のアップデートを機に無料にして広告を表示することにしました。広告を表示するだけではなんなので In App Purchase に対応して広告非表示アイテムを売るようにしました。Tier2で売り出してたころは月に1つ売れるかどうかといった感じでした。たくさんの人に使ってもらいたいので無料にしました。

ここ最近のインストール数

開発当初目標ダウンロード数は100/1日でしたが実際は1/10程度とかなり厳しい結果になっています。なんとかダウンロード数が伸びればと思いこの記事を書きました。というわけで LinkedWord をよろしくおねがいします。