はじめに
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に記事を保存
- 記事タイトルの一覧表示
- 記事のサムネイルを表示
- 記事の詳細を表示
こんな感じで動作します。
開発環境はXCode7.2を使用しています。
ソースコードは下記コマンドで取得できます。
git clone --branch v1.5 https://github.com/tjnet/NewsAppWithSwift.git
記事はここから取得します
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さんから取得させて頂きます
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を読んで頂けると、理解できるかと、、、
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に保存したデータの内容を確認できます
- Alamofire Tutorial Part 2: Progress and Caching - Ray Wenderlich
- swift - Understanding Alamofire's Response Object Serialization use of Closures - Stack Overflow
- AlamofireとSwiftyJSONでAPIを叩くチュートリアル - Qiita
- antitypical/Result: Swift type modelling the success/failure of arbitrary operations.
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オブジェクトにマッピングしています。
import Foundation
import ObjectMapper
class FeedResponse: Mappable {
var feed: Feed?
required init?(_ map: Map){
}
func mapping(map: Map) {
feed <- map["feed"]
}
}
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 が参考になりそうです。
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"
}
}
次はデータを保存します
let uiRealm = try! Realm()
プロジェクトのすべてのswiftファイルでRealm objectをシェアするため、これをAppDelegate.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
記事の一覧を表示
func readEntriesAndUpdateUI(){
let predicate = NSPredicate(format: "category = %@", self.title!)
lists = uiRealm.objects(Entry).filter(predicate)
self.tableView.reloadData()
}
JSONパースとRealmへのデータ保存が終了したら、tableViewを更新します。
tableViewの表示部分は、こんな感じで実装しています。
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
}
func configure(entry: Entry){
titleLabel.text = entry.title
descriptionLabel.text = entry.contentSnippet
self.link = entry.link
}
ニュース記事のサムネイルの表示
画像の取得処理を非同期処理で行いたいので、SDWebImageを使用した実装にしています。
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)
}
}
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
})
})
}
}
import UIKit
class ArticleAPI {
static let ogpImage = "http://api.hitonobetsu.com/ogp/analysis?url="
}
サムネイルの生成に関しては、OGP(Open Graph Protocol)データ取得APIを使用させて頂きました。
画像が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