FirebaseとSwiftyJSONを使ってSNSを作る

# はじめに
はじめまして！！Life is Tech!でメンターをしているえーえすです。
[Life is Tech ! Kanto Advent Calendar 2022]("https://qiita.com/advent-calendar/2022/life-is-tech-kanto")の3日目ということで、今回はプログラミングをやっているなら必ず一回は作ってみたいSNSの中でも、特に「画像も投稿できるSNS」のデータ処理まわりを考えてみたいなと思います。また、プログラミングを勉強している人が書く前提でこの記事を作っているので、多少遠回りですがJSON型などの概念と直にふれあう形を採用しています。何をしているかの理解を最優先にできたらいいなと思っています。
# 概要
FirebaseRealtimeDatabase・FirebaseStorageとSwiftyJSONを組み合わせて、Twitterみたいに画像と文章を投稿できるSNSのデータ取り扱い部分を作ってみようと思います。
# できること
- 画像と文章を投稿→Firebaseで保存
- 投稿を取得→SwiftyJSONでJSONから任意のclassにする
# 投稿の処理
### 投稿のclass定義
今回は投稿のclassを作ってデータの取り扱いをしようかなと思います。
```Post.swift
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から投稿ボタンが押されたとき、こんな感じになると思います。
```PostView.swift
let post = Post(text: text, image: imageView.image)
```
FirebaseStorageを作るときはロケーションの設定を間違えないように気をつけてください！！！間違えるとプロジェクト作り直しになります。
![313791572_485554010028738_2923033290674908022_n.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2823873/551aa3ba-c32b-5c87-f543-c4972a167e83.png)


まずは画像をアップロードします。いくつか文法はあると思いますがこんな感じで。
```FirebaseAPI.swift
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
...
```
一行ごとに解説していきます

```swift
guard let data = post.image.jpegData(compressionQuality: 0.2) else {
            
    return
}
```
ここで`image.jpegData`としているのは、`image.pngData`だとなぜか90°くらい回転した画像がFirebaseに保存されることがあるからです…jpegだと今のところそういったバグは確認されてません。`compressionQuality`で圧縮率を決めてる感じですね。おもたすぎるファイルはアップロードできないので、ここで調節するのもいいでしょう。

```swift
let imageIDRef = imageRef.child("gs://~~~.appspot.com").child(post.id)
        
let uploadTask = imageIDRef.putData(data)
```
imageIDRefで保存する場所のようなものを指定しています。`gs://~~~.appspot.com`はFirebaseのWebページから取ってきましょう。`imageIDRef.putData`とすることで、その場所に画像データを保存する指示を出せます。

```swift
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のアップロード**
```FirebaseAPI.swift
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入れなくてもいいんじゃね？って感じですが、取得の際の構造がちょいわかりやすいからこうします）

**投稿時のコード全体**
```FirebaseAPI.swift
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はここで使います
```FirebaseAPI.swift
import SwiftyJSON
```
```FirebaseAPI.swift
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`から解説していきますね。（名前があんまり良くないかも。）

```swift
    let keys = parsedData.allKeys as! [String]
```
さて、ここが（この方法で）Firebaseからデータを取ってくる上で**いっっっちばん難しくて大事な部分です**。
Firebaseに上がっているJSON型は、投稿のデータそれぞれが投稿のIDに紐づけられてます。なので他の人の投稿などはIDがわからず、普通にJSONに立ち向かおうとするとどうやって奥に潜り込んでいけばいいのかわからなくなります。3年前の僕はここで大いに詰まったのをよく覚えてます。

そこでまずは、JSONをNSDictionaryに変えます。そうすることでID部分を`.allKeys`と取り出すことができるようになります！
```swift
    var posts = [Post]()
        
    for key in keys {
            
        let postData = parsedData[key] as! NSDictionary
```
データを運ぶように配列を用意し、各IDに対して`parsedData[key] as! NSDictionary`と投稿データを取り出していますね。（取り出したデータもNSDictionaryとしてしまっていますが、こうでなくてもいいのかも。）
```swift
        getImage(url: postData["imageURL"] as! String, completionHandler: { image in
```
そして保存されていた画像のURLから画像を取得します。先に`getImage`に移ります。
```swift
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`に画像を手渡し、続きが実行されます。
```swift
            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からデータを取ってくる本体を書いていきます。
```swift
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についてのめちゃ有能記事を投稿してくれるみたいなので！楽しみにしています！