NCMBのSwift SDKとWebSocketを使ってチャットアプリを作る（その1：画面の説明とSDKの導入）

## ハンズオン開催

こちらの内容を元にオンラインハンズオンを開催します。1時間程度になりますので、ご興味があればぜひご参加ください。

- [11月09日13時〜 Swift × NCMBでチャットアプリを作ろう - connpass](https://ncmb.connpass.com/event/228559/)
- [11月11日17時〜 Swift × NCMBでチャットアプリを作ろう - connpass](https://ncmb.connpass.com/event/228560/)

NCMBのSwift SDKを使ってデモアプリを作ってみます。リアルタイム通信系は人気があるのですが、NCMBでは残念ながらWebSocketは使えません。そこで今回は[PieSocket](https://www.piesocket.com/)というWebSocketを提供するサービスと組み合わせて、Swift製のチャットアプリを作ってみます。

今回は画面の説明とSDKの導入までを進めます。

## コードについて

今回のコードは[NCMBMania/Swift_Chat_Demo](https://github.com/NCMBMania/Swift_Chat_Demo)にアップロードしてあります。実装時の参考にしてください。

## 利用技術について

今回は次のような組み合わせになっています。

- Swift 5.4
- Xcode 12.5.1
- NCMB
- PieSocket

## 仕様について

PieSocketはWebSocketだけを提供するので、データを保存しません。そのためWebSocketにつながっている時のメッセージは画面に表示できますが、立ち上げ直すとすべてのメッセージが消えてしまいます。そこで、NCMBのデータストアにメッセージを保存して、アプリを立ち上げた時にメッセージを再現できるようにします。

## 利用する機能について

チャットアプリで利用するNCMBの機能は次の通りです。

- 認証機能
  - 匿名認証
- データストア
  - チャットクラスへのデータ登録、一覧表示

## 画面について

今回はSwiftUIを以下の5つのViewに分けています。

- ContentView
- NameView
- ChatView
- ChatMessageRow
- ChatInputView

### ContentView

ログイン状態に応じて表示を分けています。

```swift
import SwiftUI
import NCMB

struct ContentView: View {
    private var user = NCMBUser.currentUser
    @State private var displayName = ""
    
    var body: some View {
        VStack {
            if displayName != "" {
                // 表示名が設定されている場合
                ChatView()
            } else {
                // 表示名がない場合
                NameView(displayName: $displayName)
            }
        }.onAppear() {
            setDisplayName()
        }
    }
    
    // 表示名を設定する関数
    func setDisplayName() -> Void {
        if let name: String = user!["displayName"] {
            displayName = name
        }
    }
}
```

### NameView

![Simulator Screen Shot - iPod touch (7th generation) - 2021-09-08 at 14.37.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f795bdd1-86f3-5944-d295-75f730d62d75.png)


チャット用の表示名を設定するViewです。

```swift
import SwiftUI
import NCMB

struct NameView: View {
    @Binding var displayName: String
    @State var name = ""
    var body: some View {
        VStack {
            TextField("お名前", text: $name) // 2
                .padding(10)
                .background(Color.secondary.opacity(0.2))
                .cornerRadius(5)
            Button("登録する", action: {
                update()
            })
        }
    }

    // 設定された表示名をNCMBの認証データに保存する関数   
    func update() {
        let user = NCMBUser.currentUser
        user?["displayName"] = name
        _ = user?.save()
        displayName = name
    }
}
```

### ChatView

チャットの表示を担当するViewです。

![Simulator Screen Shot - iPod touch (7th generation) - 2021-09-08 at 13.54.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/27832016-e8e8-107c-5aaa-085d1e9e403e.png)


```swift
import SwiftUI
import NCMB

struct ChatView: View {
    @ObservedObject var chat = ChatScreenModel()
    var body: some View {
        VStack {
            ScrollView {
                ScrollViewReader { proxy in
                    LazyVStack(spacing: 8) {
                        ForEach(chat.messages, id: \.objectId ) { message in
                            ChatMessageRow(message: message)
                        }
                    }
                    .onChange(of: chat.messages.count) { _ in
                        scrollToLastMessage(proxy: proxy)
                    }
                }
            }
            ChatInputView(chat: chat)
        }
        .onAppear() {
            chat.connect()
            getPastMessages()
        }
    }
    
    // NCMBに保存されているメッセージを取得する関数
    func getPastMessages() {
      // 2回目以降の記事で記述
    }
    
    // 自動スクロール用
    private func scrollToLastMessage(proxy: ScrollViewProxy) {
        if let lastMessage = chat.messages.last { // 4
            withAnimation(.easeOut(duration: 0.4)) {
                proxy.scrollTo(lastMessage.objectId, anchor: .bottom) // 5
            }
        }
    }
}
```

### ChatMessageRow

チャットメッセージの一行分の表示を行うViewです。

```swift
import SwiftUI
import NCMB

struct ChatMessageRow: View {
    @State var message: NCMBObject
    // 日付のフォーマット（時刻のみ）
    static private let dateFormatter: DateFormatter = {
            let formatter = DateFormatter()
            formatter.dateStyle = .none
            formatter.timeStyle = .short
            return formatter
        }()
    
    var body: some View {
        HStack {
            if isMe() {
                Spacer()
            }
            VStack(alignment: .leading, spacing: 6) {
                HStack {
                    Text(message["displayName"]! as String)
                        .fontWeight(.bold)
                        .font(.system(size: 12))
                    
                    Text(Self.dateFormatter.string(from: createDate()))
                        .font(.system(size: 10))
                        .opacity(0.7)
                }
                
                Text(message["body"]! as String)
            }
            .foregroundColor(isMe() ? .white : .black)
            .padding(10)
            .background(isMe() ? Color.blue : Color(white: 0.95))
            .cornerRadius(5)
            
            if !isMe() {
                Spacer()
            }
        }
    }
    
    // 自分宛かどうか判定する関数
    func isMe() -> Bool {
        if let userId: String = message["userId"] {
            let user = NCMBUser.currentUser
            return userId == user!.objectId
        }
        return false
    }
    
    // 日付をフォーマットに沿って返す
    func createDate() -> Date {
        if let createDate: String = message["createDate"] {
            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
            return dateFormatter.date(from: createDate)!
        }
        return Date()
    }
}
```

### ChatInputView

チャットのメッセージ入力を担当するViewです。

```swift
import SwiftUI
import NCMB

struct ChatInputView: View {
    @State private var message = ""
    @State var chat: ChatScreenModel
    
    var body: some View {
        HStack {
            TextField("Message", text: $message)
                .padding(10)
                .background(Color.secondary.opacity(0.2))
                .cornerRadius(5)
            
            Button(action: {
                send()
            }) {
                Image(systemName: "arrowshape.turn.up.right")
                    .font(.system(size: 20))
            }
            .padding()
            .disabled(message.isEmpty)
        }
        .padding()
    }
    
    // チャットメッセージを送信する関数
    func send() {
      // 2回目以降の記事で解説
    }
}
```

## キーの管理

今回は3つのキーを利用します。

- NCMBのアプリケーションキー
- NCMBのクライアントキー
- PieSocketのアプリケーションキー

コード中に直書きするのは良くないので、Property Listを使います。APIKey.plistというファイルを作成して、その中に3つのキーを定義します。

- PieSocketApiKey
- ApplicationKey
- ClientKey

それぞれStringで、キーを入力します。

そしてそのファイルを扱うKeyManagerを作成します。こちらの内容は[API keyを.plistにStringで保存して隠す(例: Google Maps Api) - Qiita](https://qiita.com/Saayaman/items/c50000e6103358c8c4d6)を参考にさせてもらいました。

```swift
import Foundation

struct KeyManager {

    private let keyFilePath = Bundle.main.path(forResource: "APIKey", ofType: "plist")

    func getKeys() -> NSDictionary? {
        guard let keyFilePath = keyFilePath else {
            return nil
        }
        return NSDictionary(contentsOfFile: keyFilePath)
    }

    func getValue(key: String) -> AnyObject? {
        guard let keys = getKeys() else {
            return nil
        }
        return keys[key]! as AnyObject
    }
}
```

このクラスを作っておけば `KeyManager().getValue(key: "ApplicationKey") as! String` のようにしてキーを扱えるようになります。

##　WebSocket用のクラスの作成

ChatScreenModel.swiftというファイルを作成します。この内容は [A simple chat app with SwiftUI and WebSockets — or: Swift in the back, Swift in the front! | by Freek Zijlmans | Medium](https://frzi.medium.com/a-simple-chat-app-with-swiftui-and-websockets-or-swift-in-the-back-swift-in-the-front-78b34c3dc912) を参考にしています。大枠としては次のようになります。メッセージを保存するため、 `messages` はNCMBObject（NCMBのデータ保存用オブジェクト）の配列となっています。

```swift
import Combine
import Foundation
import NCMB

final class ChatScreenModel: ObservableObject {
    private var webSocketTask: URLSessionWebSocketTask?
    @Published var messages: [NCMBObject] = []
    
    // WebSocket（今回はPieSocket）への接続を行います
    func connect() {
        let channelId = "1"
        let url = URL(string: "wss://free3.piesocket.com/v3/\(channelId)?api_key=\(KeyManager().getValue(key: "PieSocketApiKey")!)&notify_self")!
        webSocketTask = URLSession.shared.webSocketTask(with: url)
        // メッセージを受け取った時に呼ばれるハンドラ
        webSocketTask?.receive(completionHandler: onReceive)
        webSocketTask?.resume()
    }
    
    // 接続解除時に実行する関数
    func disconnect() {
        webSocketTask?.cancel(with: .normalClosure, reason: nil)
    }
    
    // メッセージを受け取った時に呼ばれる関数
    private func onReceive(incoming: Result<URLSessionWebSocketTask.Message, Error>) {
      // 2回目以降の記事で解説
    }
    
    // NCMBObjectをDictionaryにして、JSON文字列にする関数
    private func makeMessage(obj: NCMBObject) -> String {
      // 2回目以降の記事で解説
    }
    
    // チャットメッセージを送信する関数
    func send(obj: NCMBObject) {
        // NCMBObjectからメッセージを作成して、送信
        webSocketTask?.send(.string(makeMessage(obj: obj)), completionHandler: { error in
            if error != nil {
                // エラーの場合
                print(error)
            }
        })
    }
}
```

## 今回のプロジェクト

今回は言語がSwift、インタフェースがSwiftUI、ライフサイクルはSwiftUI Appとしています。

## SDKのインストール

FileメニューからSwift Packages > Add Package Dependencyと選択します。

![ScreenShot_ 2021-08-02 9.35.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/dea9dcdc-3368-7992-67d4-8375355bf2ff.png)

出てきたダイアログでSwift SDKのGitリポジトリURLを入力します。GitHubのリポジトリでHTTPSとして取得できるもの、または下記URLになります。

![ScreenShot_ 2021-08-02 9.35.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/8d5d62cd-cf3f-f535-8f71-f1e61569674a.png)

```
https://github.com/NIFCLOUD-mbaas/ncmb_swift.git
```

バージョンは最新のものでかまいません。

![ScreenShot_ 2021-08-02 9.36.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/9e873825-38be-97e2-edd3-44776cc72d22.png)

後はFinishボタンを押せば完了です。

![ScreenShot_ 2021-08-02 9.36.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/7f74527e-8de8-d5cf-b433-c16b538c24f1.png)

## 初期化

今回はSwiftUIを利用しています。ライフサイクルもSwiftUIです。

まずSDKをインポートします。

```swift
import SwiftUI
import NCMB
```

次に `scenePhase` を追加します。

```swift
@main
struct ChatApp: App {
    // 追加
    @Environment(\.scenePhase) private var scenePhase
```

後は body 内で `onChange` を使って初期化します。

```swift
var body: some Scene {
    WindowGroup {
        ContentView()
    }.onChange(of: scenePhase) { scene in
        switch scene {
        case .active:
            // キーの設定
            let applicationKey = KeyManager().getValue(key: "ApplicationKey") as! String
            let clientKey = KeyManager().getValue(key: "ClientKey") as! String
            // 初期化
            NCMB.initialize(applicationKey: applicationKey, clientKey: clientKey)
        case .background: break
        case .inactive: break
        default: break
        }
    }
}
```

## 匿名認証

今回は匿名認証（ID、パスワードを使わない、デバイス固有に生成したUUIDを使った認証）を利用します。そこで、初期化した後にSwift SDKの匿名認証を有効にします。

```swift
NCMBUser.enableAutomaticUser()
```

### 認証状態の確認

次に認証状態を確認する `checkAuth` 関数を呼び出します。内容は次の通りです。 `NCMBUser.currentUser` が nil の場合は認証されていないので、匿名認証を実行します。

```swift
func checkAuth() -> Void {
    // 認証データがあれば処理は終了
    if NCMBUser.currentUser != nil {
        return;
    }
    // 匿名認証実行
    _ = NCMBUser.automaticCurrentUser()
}
```

### セッションの有効性チェック

認証されている場合でも、セッションの有効性は確認していません。ローカルにある認証データを復元している状態のためです。そこで、データストアに一度アクセスを行い、API通信の有効性を確認します。

```swift
func checkSession() -> Bool {
    var query : NCMBQuery<NCMBObject> = NCMBQuery.getQuery(className: "Todo")
    query.limit = 1 // レスポンス件数を最小限にする
    // アクセス
    let results = query.find()
    // 結果の判定
    switch results {
    case .success(_): break
    case .failure(_):
        // 強制ログアウト処理
        _ = NCMBUser.logOut()
        return false
    }
    return true
}
```

上記コードでログアウト処理を実行していますが、これは必ず失敗します。セッションが無効になっているため、ログアウトAPIの実行もまた、失敗するためです。そこで `NCMB/NCMBUser.swift` を開いて `logOutInBackground` を次のように修正します。

```swift
public class func logOutInBackground(callback: @escaping NCMBHandler<Void>) -> Void {
    NCMBLogoutService().logOut(callback: {(result: NCMBResult<NCMBResponse>) -> Void in
        // レスポンスに関係なくログアウト処理
        deleteFile()
        _currentUser = nil
        switch result {
            case .success(_):
                callback(NCMBResult<Void>.success(()))
                break
            case let .failure(error):
                callback(NCMBResult<Void>.failure(error))
                break
        }
    })
}
```

最終的にChatAppの内容は次のようになります。

```swift
struct ChatApp: App {
    @Environment(\.scenePhase) private var scenePhase
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { scene in
            switch scene {
            case .active:
                // キーの設定
                let applicationKey = KeyManager().getValue(key: "ApplicationKey") as! String
                let clientKey = KeyManager().getValue(key: "ClientKey") as! String
                // 初期化
                NCMB.initialize(applicationKey: applicationKey, clientKey: clientKey)
                NCMBUser.enableAutomaticUser()
                checkAuth()
                if checkSession() == false {
                    checkAuth()
                }
            case .background: break
            case .inactive: break
            default: break
            }
        }
    }
    
    func checkAuth() -> Void {
        // 認証データがあれば処理は終了
        if NCMBUser.currentUser != nil {
            return;
        }
        // 匿名認証実行
        _ = NCMBUser.automaticCurrentUser()
    }
    
    // セッションの有効性をチェックする関数
    func checkSession() -> Bool {
        var query : NCMBQuery<NCMBObject> = NCMBQuery.getQuery(className: "Todo")
        query.limit = 1 // レスポンス件数を最小限にする
        // アクセス
        let results = query.find()
        // 結果の判定
        switch results {
        case .success(_): break
        case .failure(_):
            // 強制ログアウト処理
            _ = NCMBUser.logOut()
            return false
        }
        return true
    }
}
```

### NCMBの管理画面を修正

最後にNCMBの管理画面でアプリ設定を開き、匿名認証を有効にします。

![FireShot_Capture_219_-_ニフクラ_mobile_backend_-_console_mbaas_nifcloud_com_15-23-35.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/36d4c49f-5d86-56c4-671a-5a25c0115fcd.jpeg)


これでSwift SDKの初期化と匿名認証処理が完了になります。

## まとめ

今回はチャットアプリの仕様と画面、NCMBの初期化までを解説しました。次はチャットメッセージの送信と、NCMBのデータストアへの保存について解説します。