はじめに
はじめまして!!Life is Tech!でメンターをしているえーえすです。
Life is Tech ! Kanto Advent Calendar 2022の3日目ということで、今回はプログラミングをやっているなら必ず一回は作ってみたいSNSの中でも、特に「画像も投稿できるSNS」のデータ処理まわりを考えてみたいなと思います。また、プログラミングを勉強している人が書く前提でこの記事を作っているので、多少遠回りですがJSON型などの概念と直にふれあう形を採用しています。何をしているかの理解を最優先にできたらいいなと思っています。
概要
FirebaseRealtimeDatabase・FirebaseStorageとSwiftyJSONを組み合わせて、Twitterみたいに画像と文章を投稿できるSNSのデータ取り扱い部分を作ってみようと思います。
できること
- 画像と文章を投稿→Firebaseで保存
- 投稿を取得→SwiftyJSONでJSONから任意のclassにする
投稿の処理
投稿のclass定義
今回は投稿のclassを作ってデータの取り扱いをしようかなと思います。
class Post {
var id = UUID()
var text = ""
var image = UIImage()
var imageURL = ""
init(text: String, image: UIImage) {
self.text = text
self.image = image
}
}
編集機能をつけるかもしれない想定で定数を使っていません。CodableとかRealmとか使ってローカルでのデータ管理するのもいいですね。
Firebaseに投稿
大まかな流れとしては、画像をFirebaseStorageに投稿→URLを取得し、それを含めてFirebaseRealtimeDatabaseに投稿する という感じで行こうと思います。
画像のアップロード
まずはUIから投稿ボタンが押されたとき、こんな感じになると思います。
let post = Post(text: text, image: imageView.image)
FirebaseStorageを作るときはロケーションの設定を間違えないように気をつけてください!!!間違えるとプロジェクト作り直しになります。
まずは画像をアップロードします。いくつか文法はあると思いますがこんな感じで。
let post = Post(text: text, image: imageView.image)
guard let data = post.image.jpegData(compressionQuality: 0.2) else {
return
}
let imageIDRef = imageRef.child("gs://~~~.appspot.com").child(post.id)
let uploadTask = imageIDRef.putData(data)
uploadTask.observe(.success) { _ in
imageIDRef.downloadURL { url, error in
if let url = url {
let downloadURL: String = url.absoluteString
...
一行ごとに解説していきます
guard let data = post.image.jpegData(compressionQuality: 0.2) else {
return
}
ここでimage.jpegData
としているのは、image.pngData
だとなぜか90°くらい回転した画像がFirebaseに保存されることがあるからです…jpegだと今のところそういったバグは確認されてません。compressionQuality
で圧縮率を決めてる感じですね。おもたすぎるファイルはアップロードできないので、ここで調節するのもいいでしょう。
let imageIDRef = imageRef.child("gs://~~~.appspot.com").child(post.id)
let uploadTask = imageIDRef.putData(data)
imageIDRefで保存する場所のようなものを指定しています。gs://~~~.appspot.com
はFirebaseのWebページから取ってきましょう。imageIDRef.putData
とすることで、その場所に画像データを保存する指示を出せます。
uploadTask.observe(.success) { _ in
imageIDRef.downloadURL { url, error in
if let url = url {
let downloadURL: String = url.absoluteString
observe(.success)
は、uploadTaskが成功、つまりFirebaseに画像データがちゃんと保存できた時に呼び出されます。今回は省略しますが、observe(.failure)
で失敗を監視できます。そしてdownloadURL
にその保存された画像のURLをとってくる感じとなっています。補足ですがif let else
でエラー処理などもできますね。
さて、URLがとってこれたのでこれを含めたその他データをFirebaseRealtimeDatabaseに投稿します。
文章とURLのアップロード
self.ref.child(post.id).updateChildValues([
"id": post.id,
"text": post.text,
"imageURL": downloadURL
])
ref
はprivate var ref = Database.database().reference()
みたいな定義がなされてます。idの場所にid含めてデータを記載します。(idの中にid入れなくてもいいんじゃね?って感じですが、取得の際の構造がちょいわかりやすいからこうします)
投稿時のコード全体
private var ref = Database.database().reference()
func uploadPost(post: Post) {
guard let data = post.image.jpegData(compressionQuality: 0.2) else {
return
}
let imageIDRef = imageRef.child("gs://~~~.appspot.com").child(post.id)
let uploadTask = imageIDRef.putData(data)
uploadTask.observe(.success) { _ in
imageIDRef.downloadURL { url, error in
if let url = url {
let downloadURL: String = url.absoluteString
self.ref.child(post.id).updateChildValues([
"id": post.id,
"text": post.text,
"imageURL": downloadURL
])
}
}
}
}
Firebaseからデータを取得
DatabaseからURLなど情報を取得→URLを使って画像を取得→表示 という流れで行きたいですね。
さてここでTimelineViewに投稿をすべて表示する時、そこにあるPostの配列を更新→didSetを使って変更を検出して画面に反映、という流れにしたいので、全てのデータを仮の配列に保存し、最後にいっぺんに送るという形を作っていきましょう。
文章とURLを取得
SwiftyJSONはここで使います
import SwiftyJSON
func getAllPosts(completionHandler: @escaping ([Post]) -> Void) {
ref.observeSingleEvent(of: .value, with: { snapshot in
let data = snapshot.value
if data is NSNull || data == nil {
print("error")
completionHandler([Post]())
}else {
let parsedData = data as! NSDictionary
let posts = self.parseArray(parsedData: parsedData)
completionHandler(posts)
}
}) { error in
print(error.localizedDescription)
}
}
func parseArray(posts: NSDictionary) -> [Post] {
let keys = parsedData.allKeys as! [String]
var posts = [Post]()
for key in keys {
let postData = parsedData[key] as! NSDictionary
getImage(url: postData["imageURL"] as! String, completionHandler: { image in
var post = Post(text: postData["text"] as! String, image: image!)
post.id = postData["id"] as! String
posts.append(post)
})
}
return posts
}
func getImage(url: String, completionHandler: @escaping (UIImage?) -> Void) {
do {
let data = try Data(contentsOf: URL(string: url)!)
completionHandler(UIImage(data: data)!)
} catch let error {
print("Error : \(error.localizedDescription)")
}
}
さて、先にparseArray
から解説していきますね。(名前があんまり良くないかも。)
let keys = parsedData.allKeys as! [String]
さて、ここが(この方法で)Firebaseからデータを取ってくる上でいっっっちばん難しくて大事な部分です。
Firebaseに上がっているJSON型は、投稿のデータそれぞれが投稿のIDに紐づけられてます。なので他の人の投稿などはIDがわからず、普通にJSONに立ち向かおうとするとどうやって奥に潜り込んでいけばいいのかわからなくなります。3年前の僕はここで大いに詰まったのをよく覚えてます。
そこでまずは、JSONをNSDictionaryに変えます。そうすることでID部分を.allKeys
と取り出すことができるようになります!
var posts = [Post]()
for key in keys {
let postData = parsedData[key] as! NSDictionary
データを運ぶように配列を用意し、各IDに対してparsedData[key] as! NSDictionary
と投稿データを取り出していますね。(取り出したデータもNSDictionaryとしてしまっていますが、こうでなくてもいいのかも。)
getImage(url: postData["imageURL"] as! String, completionHandler: { image in
そして保存されていた画像のURLから画像を取得します。先にgetImage
に移ります。
func getImage(url: String, completionHandler: @escaping (UIImage?) -> Void) {
do {
let data = try Data(contentsOf: URL(string: url)!)
completionHandler(UIImage(data: data)!)
} catch let error {
print("Error : \(error.localizedDescription)")
}
}
ここは定石通りみたいな感じありますが、urlからData(contents of: ~)
と画像の取得を試みて、ダメだったらcatchに逃げるみたいなそんな感じですね。できたらcompletionHandler
に画像を手渡し、続きが実行されます。
var post = Post(text: postData["text"] as! String, image: image!)
post.id = postData["id"] as! String
posts.append(post)
})
}
return posts
画像が取得できてそうなのでPostを作っていきます。
(このコード書いたの3年くらい前なのですが、なんか!を多用したり取得できてない時の対応がおざなりだったりであんまりいいコードじゃないなぁって思ったりしてます)
さて、JSONで取ってきたデータがめでたくPostの配列にできる機構ができたので、Firebaseからデータを取ってくる本体を書いていきます。
func getAllPosts(completionHandler: @escaping ([Post]) -> Void) {
ref.observeSingleEvent(of: .value, with: { snapshot in
let data = snapshot.value
if data is NSNull || data == nil {
print("error")
completionHandler([Post]())
}else {
let parsedData = data as! NSDictionary
let posts = self.parseArray(parsedData: parsedData)
completionHandler(posts)
}
}) { error in
print(error.localizedDescription)
}
}
ref
は投稿の時に使ったのと同じだと思ってください。Database.database().reference()
みたいな難しそうな書き方してますが、実際はURLと同じ、データの住所を書いたものですね。
ref.observeSingleEvent(of: .value, with: { snapshot in
でデータがsnapshotに入ってきます。(必要に応じてrefに.child("~")などをつけましょう)それが空っぽで無いかを探ろうとするとなんかNSNull
とnil
両方のチェックが必要らしいですね。データが空で無いなら先ほどのメソッドでPostの配列を作り、これを返します。
おおまかな流れとして、DatabaseからID・文章・画像URLを取得→取得したURLを使って画像を取得→取得したこれらからPostのインスタンスを作成→配列に加え、返す という感じになっています。それぞれが完了してから矢印を辿っていかないといけないというのも大変なところです。completionHandler
の中なら動作が完了するまで時間が止まるので、それをうまく使ってあげましょう。
まとめ
さて長々と画像含めた投稿の流れを作ってきましたが、実はこんなまどろっこしいことしなくてもCodableを使えば秒でJSONからPostの配列を作ることができます。しかしそれだとJSONとは何者なのかとかのいい練習にはならないんじゃ無いかな…という考えが僕の中にあるので、この方法を採用することもあったりなかったり。
また、もう一つの難関箇所として、DatabaseとStorageを併用することでデータ取得の流れをコードに反映させることが難しかったりします。こういったことを理解し、多少これらに慣れる足がかりとして画像付きSNSってのはいいテーマなんじゃ無いかなと思っています。
メンバーにこれをやってもらう時には、文法とか知らなきゃ無理だろ!ってところは最初から教えて、.allKeys
とかの疑問が出たらそこは教えて、あとは流れを自分で頑張って実装してもらうみたいなのがいいのかなって思ってます。
さてさて明日は、つぼつぼがzoomについてのめちゃ有能記事を投稿してくれるみたいなので!楽しみにしています!