52
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

はじめに

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はこんな感じで返ってきます。

.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リーダーを作ってみた - 株式会社エウレカ

52
50
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?