Posted at

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

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