iPhone
iOS
Swift
iOS9
Swift2.0

swift初心者がSmartNews風ニュースアプリを作ってみる過程を晒す(6) - Alamofire + Object Mapper + Realm + SDWebImageで最低限動くニュースアプリを作る

More than 1 year has passed since last update.

はじめに

swiftはほとんど未経験ですが、SmartNews風ニュースアプリを作ってみて、その過程をさらしています。

前回は、こんな記事を書きました。

swift初心者がSmartNews風ニュースアプリを作ってみる過程を晒す(5) - ニュースアプリにおけるApp Transport Securityについて考える - Qiita

今回は、ニュース記事としての最低限の機能を実装して、一通り動くものを作成します。

現在の進捗

前回は、ニュースアプリにおけるApp Transport Securityについて考えました。

前回までのソースコードは下記コマンドで取得できます。

git clone --branch v1.4 https://github.com/tjnet/NewsAppWithSwift.git

今回、作ったもの

今回は、ニュースアプリとしての最低限の動作を実現するため、以下の機能を実装しました。

  • Web APIから記事の取得
  • Realmに記事を保存
  • 記事タイトルの一覧表示
  • 記事のサムネイルを表示
  • 記事の詳細を表示

こんな感じで動作します。

20160111_23_35

開発環境はXCode7.2を使用しています。

ソースコードは下記コマンドで取得できます。

git clone --branch v1.5 https://github.com/tjnet/NewsAppWithSwift.git

記事はここから取得します

ViewController.swift
        var feeds: [Dictionary<String, String>] =
        [
            [
                "link": "https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&q=http://menthas.com/top/rss",
                "title": "top"
            ],
            [
                "link": "https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&q=http://menthas.com/ruby/rss",
                "title": "ruby"
            ],
            [
                "link": "https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&q=http://menthas.com/ios/rss",
                "title": "ios"
            ],
            [
                "link": "https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&q=http://menthas.com/infrastructure/rss",
                "title": "infrastructure"
            ],
        ]

記事は、今、話題のMenthas.comさんから取得させて頂きます :bow:
URLが重複していて冗長な記述なので、本当はどこかにまとめた方が良いですね.

Google Ajax Feed APIを使用しているのは、RSSフィードを直接JSONとして取得できて便利だからです。
RSSフィードをJSONとして受け取る方法~Google Feed APIの応用 | 56docブログ

ニュース記事を取得してみます

ネットワーク通信にはAlamofireを使用しています。
ニュース記事の取得元となるURLはfetchFromに代入しています。

JSONはこんな感じで返ってきます。

{
  responseData: {
  feed: {
    feedUrl: "http://menthas.com/top/rss",
    title: "Menthas[top]",
    link: "http://menthas.com",
    author: "",
    description: "プログラマ向けのニュースキュレーションサービスです。",
    type: "rss20",
    entries: [
     {

responseObjectメソッドの第1引数には、オブジェクトマッピング(オブジェクトとJSONレスポンスの紐付け)を行うためのkey path(ここではresponseData)を指定しています。第2引数にはクロージャを指定します。そのクロージャでは、Alamofire.Response型の引数を扱うことができます。

記事データを取得するのは、response.result.valueを取得すれば良さそうです。
Alamofire.Responseの定義やAlamofireとSwiftyJSONでAPIを叩くチュートリアル - Qiitaを読んで頂けると、理解できるかと、、、:bow:

TableViewController.swift
Alamofire.request(.GET, fetchFrom).responseObject("responseData") { (response: Alamofire.Response<FeedResponse, NSError>) in

  guard let entries = response.result.value?.feed?.entries else {
        return
  }

  print(entries)
  print("Our default realm is located at \(RLMRealm.defaultRealm().path)")

  //この後、取得した記事をRealmに保存

}

上記のコードについて、少し補足します。

  guard let entries = response.result.value?.feed?.entries else {
        return
  }

ここは、早めにreturnする方が、if letで書くよりもネストが深くならずにすみそうなので、こう書いています。

print("Our default realm is located at \(RLMRealm.defaultRealm().path)")

ここで表示するpathをRealm Browserで開くと、Realmに保存したデータの内容を確認できます

JSONレスポンスをSwiftのオブジェクトにマッピングします

JSONは、こんな形式で返ってきます。

{
  responseData: {
  feed: {
    feedUrl: "http://menthas.com/top/rss",
    title: "Menthas[top]",
    link: "http://menthas.com",
    author: "",
    description: "プログラマ向けのニュースキュレーションサービスです。",
    type: "rss20",
    entries: [
     {
       title: "Windows 7/8サポート、Skylake搭載端末上の場合は2017年7月17日までに - ITmedia ニュース",
       link: "http://www.itmedia.co.jp/news/articles/1601/17/news009.html",
       author: "",
       publishedDate: "Sat, 16 Jan 2016 19:13:11 -0800",
       contentSnippet: "MicrosoftがWindowsのサポートポリシーを変更し、プロセッサがIntelの第6世代Coreシリーズ(コードネーム:Skylake)の端末にインストールしたWindows 7およびWindows ...",
       content: "MicrosoftがWindowsのサポートポリシーを変更し、プロセッサがIntelの第6世代Coreシリーズ(コードネーム:Skylake)の端末にインストールしたWindows 7およびWindows 8.1については、サポートを2017年7月17日までとした。",
       categories: [ ]
      ...(略)...

},

このJSONレスポンスを、AlamofireObjectMapperを用いてFeedResponseオブジェクト、Feedオブジェクト、Entryオブジェクトにマッピングしています。

FeedResponse.swift
import Foundation
import ObjectMapper

class FeedResponse: Mappable {
    var feed: Feed?

    required init?(_ map: Map){

    }

    func mapping(map: Map) {
        feed <- map["feed"]
    }

}
Feed.swift
import Foundation
import ObjectMapper

class Feed: Mappable {
    var entries: [Entry]?

    required init?(_ map: Map){

    }
    func mapping(map: Map) {
        entries <- map["entries"]
    }
}

dynamic var は、propertyがrealmのデータのアクセサになっていることを意味しています。
initializerの宣言の仕方については、 The Swift Programming Language (Swift 2.1): Initialization が参考になりそうです。

Entry.swift
import Foundation
import ObjectMapper
import RealmSwift

class Entry: Object, Mappable {

    dynamic var title = ""
    dynamic var link = ""
    dynamic var contentSnippet = ""
    dynamic var category = ""

    required convenience init?(_ map: ObjectMapper.Map) {
        self.init()
    }
    func mapping(map: Map) {
        title <- map["title"]
        link <- map["link"]
        contentSnippet <- map["contentSnippet"]
    }    
    override class func primaryKey() -> String {
        return "link"
    }
}

次はデータを保存します

AppDelegate.swift
let uiRealm = try! Realm()

プロジェクトのすべてのswiftファイルでRealm objectをシェアするため、これをAppDelegate.swiftの冒頭に書きます。

TableViewController.swift
      try! uiRealm.write { () -> Void in

          for entry in entries {
              entry.category = self.title!

              //see https://github.com/realm/realm-cocoa/issues/2149
              //see https://realm.io/docs/swift/latest/api/Classes/Realm.html#/s:FC10RealmSwift5Realm3adduRq_Ss12SequenceTypedqqq_S1_9GeneratorSs13GeneratorType7ElementCS_6Object_FS0_FTq_6updateSb_T_

              uiRealm.add(entry, update: true)

          }
      }
      self.readEntriesAndUpdateUI()

ぐるぐるループして、entryをRealmに保存するためにwriteメソッドをcallしています。

uiRealm.add(entry, update: true) の第2引数に指定しているtrueは、既に保存されているオブジェクトであれば、同じprimary keyでupdateすることを意味しています。詳細はIf true will try to update existing objects with the same primary key.をご参照ください。

記事のUIを作成

記事一覧の一つ一つの記事のUIは、カスタムセルにAuto Layoutを適用して作っていきます。

カスタムセルの作り方は、このあたりの記事が、参考になりそうです。
Storyboards Tutorial in iOS 9: Part 1 - Ray Wenderlich

Auto Layoutについては、以下の記事が参考になりそうです。

Auto Layout Tutorial in iOS 9 Part 1: Getting Started - Ray Wenderlich
Auto Layout Tutorial in iOS 9 Part 2: Constraints - Ray Wenderlich

記事の一覧を表示

TableViewController.swift
    func readEntriesAndUpdateUI(){

        let predicate = NSPredicate(format: "category = %@", self.title!)
        lists = uiRealm.objects(Entry).filter(predicate)

        self.tableView.reloadData()
    }

JSONパースとRealmへのデータ保存が終了したら、tableViewを更新します。

tableViewの表示部分は、こんな感じで実装しています。

TableViewController.swift
    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

        let cell : FeedTableViewCell = tableView.dequeueReusableCellWithIdentifier("FeedTableViewCell") as! FeedTableViewCell

        // Configure the cell...
        let entry = lists[indexPath.row] as Entry

        cell.configure(entry)


        return cell
    }

FeedTableViewCell.swift
    func configure(entry: Entry){
        titleLabel.text = entry.title
        descriptionLabel.text = entry.contentSnippet
        self.link = entry.link
    }

スクリーンショット 2016-01-20 0.02.03.png

ニュース記事のサムネイルの表示

画像の取得処理を非同期処理で行いたいので、SDWebImageを使用した実装にしています。

TJExtensions.swift
import Foundation
import UIKit
import WebImage

extension UIImageView {
    func loadWebImage(url:NSURL!){
        self.sd_setImageWithURL(url)
    }

    func loadWebImage(url:NSURL!, placeholderImage:UIImage!) {
        self.sd_setImageWithURL(url, placeholderImage: placeholderImage)
    }

    func loadWebImage(url:NSURL!, placeholderImage:UIImage!,completeion:SDWebImageCompletionBlock){
        self.sd_setImageWithURL(url, placeholderImage: placeholderImage, completed: completeion)
    }
}
FeedTableViewCell.swift
class FeedTableViewCell: UITableViewCell {

    @IBOutlet weak var thumbnailImageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var descriptionLabel: UILabel!

    var link: String! {
        didSet {
            Alamofire.request(.GET, ArticleAPI.ogpImage + link).responseObject("") { (response: Response<OGPResponse, NSError>) in
                var imageUrl: NSURL?
                let ogpResponse = response.result.value
                if let ogpImgSrc = ogpResponse?.image {
                        imageUrl = NSURL(string: ogpImgSrc)
//                        print(imageUrl)
                        self.setThumbnailWithFadeInAnimation(imageUrl)
                }
            }
        }
    }

    func setThumbnailWithFadeInAnimation(imageUrl: NSURL!){
        self.thumbnailImageView.loadWebImage(imageUrl, placeholderImage: nil, completeion: {
            (image, error, cacheType, url) ->Void in
            self.thumbnailImageView.alpha = 0

            UIView.animateWithDuration(0.25,
                animations: {
                    self.thumbnailImageView.alpha = 1
            })
        })
    }
}
API.swift
import UIKit

class ArticleAPI {
    static let ogpImage = "http://api.hitonobetsu.com/ogp/analysis?url="
}

サムネイルの生成に関しては、OGP(Open Graph Protocol)データ取得APIを使用させて頂きました。:bow:
画像がOGPに設定されていなくとも、可能であれば代替の画像を返してくれるので、非常に便利です。

一覧画面から記事タイトルをタップすると、ニュース記事の内容を表示

最後に、記事一覧画面から記事をタップした時の画面遷移です。

  override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        let entry = lists[indexPath.row] as Entry

        let svc = SFSafariViewController(URL: NSURL(string: entry.link)!)
        self.presentViewController(svc, animated: true, completion: nil)


        reloadRowsAtIndexPath(indexPath)
    }

SFSafariViewController Class Referenceを使用して、記事の内容を表示しています。これはiOS9で導入された新しいcontrollerです。

UIWebViewで実装することもできますが、Back/Forwardボタン等を自分で実装するのもシンドイので、今回はSFSafariViewControllerを使用しました。

アプリから離脱させることなくブラウザとしての機能を提供でき、わずか数行のコードでwebコンテンツを表示できます。

感想

...ひとまず、最低限の動きはできるようになりました。

何だかViewControllerが肥大化して、今後の仕様変更がツラくなりそうです。
このままではテストも書きにくそうですね。

次回以降は、今後の拡張を踏まえてアーキテクチャの変更(MVVMへ移行、ReactiveCocoaの導入)やテストコードの追加(Quickの導入)を行います。

ここまでのソースコードは下記コマンドで取得できます。

git clone --branch v1.5 https://github.com/tjnet/NewsAppWithSwift.git

参考

SwiftだけでRSSリーダーを作ってみた - 株式会社エウレカ