0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【ハンズオン資料】NCMBのSwift SDKとWebSocketを使ってチャットアプリを作る

Last updated at Posted at 2021-09-22

ハンズオン開催

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

コードについて

今回のコードはNCMBMania/Swift_Chat_Handsonにアップロードしてあります。こちらをダウンロード、またはcloneしてください。

利用技術について

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

  • Swift 5.4/5/5
  • Xcode 12.5.1/13.0
  • NCMB
  • PieSocket

仕様について

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

利用する機能について

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

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

画面について

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

  • ContentView
  • NameView
  • ChatView
  • ChatMessageRow
  • ChatInputView

ContentView

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

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

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

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() {
        // 後ほど実装します
    }
}

ChatView

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

Simulator Screen Shot - iPod touch (7th generation) - 2021-09-08 at 13.54.42.png

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です。

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です。

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で、キーを入力します。PieSocketApiKeyは oZ49l7gDlA5SSfvwwSurwrFNOB0EBVLLVKM1MrLw を利用してください。

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

// 実装済み
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 を参考にしています。大枠としては次のようになります。メッセージを保存するため、 messages はNCMBObject(NCMBのデータ保存用オブジェクト)の配列となっています。

// 実装済み
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>) {
      // 後ほど解説
    }
    
    // NCMBObjectをDictionaryにして、JSON文字列にする関数
    private func makeMessage(obj: NCMBObject) -> String {
      // 後ほど解説
    }
    
    // チャットメッセージを送信する関数
    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

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

ScreenShot_ 2021-08-02 9.35.56.png

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

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

ScreenShot_ 2021-08-02 9.36.05.png

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

ScreenShot_ 2021-08-02 9.36.40.png

初期化(ChatApp.swift)

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

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

// 記述済み
import SwiftUI
import NCMB

次に body 内で onChange を使って初期化します。

// 記述してください(1/8)
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の初期化
            NCMB.initialize(applicationKey: applicationKey, clientKey: clientKey)
        case .background: break
        case .inactive: break
        default: break
        }
    }
}

匿名認証

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

// 記述してください(2/8)
// 匿名認証の有効化
NCMBUser.enableAutomaticUser()

認証状態の確認

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

// 記述してください(3/8)
func checkAuth() -> Void {
    // 認証データがあれば処理は終了
    if NCMBUser.currentUser != nil {
        return;
    }
    // 匿名認証実行
    _ = NCMBUser.automaticCurrentUser()
}

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

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

// 記述してください(4/8)
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 を次のように修正します。

// 今回は紹介のみとします
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の内容は次のようになります。

// 完成形の確認です
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の初期化
                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

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

チャットメッセージの送信(ChatView.swift)

フローは次のようになります。

  • NCMBのデータストアにチャットメッセージを保存する
  • WebSocketでメッセージを送信する

NCMBのデータストアにチャットメッセージを保存する

入力されたテキストと、ログインユーザの情報を紐付けてNCMBに保存します。これは ChatInputView の send メソッドに実装します。注意点としてはACLを使ってアクセスコントロールを設定しているところでしょうか。

// 記述してください(5/8)
// チャットメッセージを送信する関数
func send() {
    // NCMBのデータストア用のオブジェクトを用意
    let obj = NCMBObject(className: "Chat")
    // 必要なデータを設定
    obj["body"] = message
    let user = NCMBUser.currentUser
    obj["userId"] = user!.objectId
    var acl = NCMBACL.empty // ACL(アクセス管理)を定義
    // * = 全員。読み込み可能、編集不可
    acl.put(key: "*", readable: true, writable: false)
    // ユーザのobjectId。指定したユーザ(投稿者)のみ読み込み可能、編集可能
    acl.put(key: user!.objectId!, readable: true, writable: true)
    obj.acl = acl // ACLを設定
    // 表示名も設定
    if let displayName: String = user!["displayName"] {
        obj["displayName"] = displayName
    }
    _ = obj.save()       // 保存
    message = ""         // 入力されていたメッセージを消す
    chat.send(obj: obj)  // WebSocketで送信
}

WebSocketでメッセージを送信する

保存後、WebSocketでメッセージを送信します。WebSocketでは基本的にテキストメッセージを送信するので、NCMBObjectから必要なデータを取り出して、JSONテキストに変換します。これは ChatScreenModel の makeMessage メソッドで行います。

// 記述済み
// チャットメッセージを送信する関数
func send(obj: NCMBObject) {
    // NCMBObjectからメッセージを作成して、送信
    webSocketTask?.send(.string(makeMessage(obj: obj)), completionHandler: { error in
        if error != nil {
            // エラーの場合
            print(error)
        }
    })
}

makeMessage は必要な情報を集めてDictionaryを作成し、それをJSONテキスト化します。日付だけISO8601形式に変換して入れています。

// 記述してください(6/8)
// NCMBObjectをDictionaryにして、JSON文字列にする関数
private func makeMessage(obj: NCMBObject) -> String {
    // Dictionaryの準備
    var json = Dictionary<String, String>()
    json["objectId"] = obj.objectId! // チャットメッセージのobjectId
    json["body"] = obj["body"] ?? "" // チャットメッセージ
    json["userId"] = obj["userId"] ?? "" // チャットの送信者
    json["displayName"] = obj["displayName"] ?? "" // チャットの表示名
    // 投稿日時
    let formatter = ISO8601DateFormatter()
    json["createDate"] = formatter.string(from: Date())
    do {
        // Dictionaryを文字列化
        let jsonData = try JSONSerialization.data(withJSONObject: json)
        return String(bytes: jsonData, encoding: .utf8)!
    } catch (let e) {
        print(e)
    }
    return "" // エラーの場合
}

WebSocketでメッセージを送信すると受信時のハンドラである onReceive メソッドが呼ばれます。ここでは送信時と逆に、受け取ったメッセージを分解してNCMBObjectオブジェクトを作成します。なお、URLSessionWebSocketTaskではメッセージを1度しか受信してくれないので、最後にもう一度受信時のハンドラを設定しておきます。

// 記述してください(7/8)
// メッセージを受け取った時に呼ばれる関数
private func onReceive(incoming: Result<URLSessionWebSocketTask.Message, Error>) {
    switch incoming {
    case let .success(message): // 正しく受け取れている場合
        // メッセージの種類に応じて処理分け(今回はテキストのみ)
        switch message {
        case let .string(msg):
            // テキストをデータ化
            let data: Data =  msg.data(using: String.Encoding.utf8)!
            do {
                // ======================================
                // 記述(ここから)
                // ======================================
                // JSONとしてパース
                let params = try JSONSerialization.jsonObject(with: data) as! Dictionary<String, String>
                // NCMBObjectとして作り直し
                let obj = NCMBObject(className: "Chat")
                obj["displayName"] = params["displayName"]
                obj.objectId = params["objectId"]
                obj["userId"] = params["userId"]
                obj["body"] = params["body"]
                obj["createDate"] = params["createDate"]
                DispatchQueue.main.async {
                    self.messages.append(obj) // メッセージの配列に追加
                }
                // ======================================
                // 記述(ここまで)
                // ======================================
            } catch {
            }
            break
        case let .data(data):
            print(data)
        @unknown default:
            print("unknown \(message)")
        }
        break
    case let .failure(err):
        print(err)
        break
    }
    webSocketTask?.receive(completionHandler: onReceive)
}

これでメッセージの送信処理が完了します。

チャットメッセージの表示

チャットメッセージの表示は ChatView にて行います。WebSocketの接続やメッセージの送受信を管理している ChatScreenModelObservedObject として定義しています。

// 記述済み
struct ChatView: View {
    @ObservedObject var chat = ChatScreenModel()

そして chat.messages にメッセージが追加されたらリストを更新しています。この時、ForEach の id は objectId になります。

// 記述済み
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)
        }
    }
}

scrollToLastMessage はリストの最後のメッセージを追いかける形で自動スクロールするためのメソッドです。

// 記述済み
// 自動スクロール用
private func scrollToLastMessage(proxy: ScrollViewProxy) {
    if let lastMessage = chat.messages.last {
        withAnimation(.easeOut(duration: 0.4)) {
            proxy.scrollTo(lastMessage.objectId, anchor: .bottom)
        }
    }
}

自分と相手のメッセージで表示を分ける

自分のメッセージか否かで表示(色や配置)を分けています。これは ChatMessageRow で実装しています。自分のメッセージかどうかはNCMBObjectにあるuserId(チャット投稿者のobjectId)を使って判定しています。

// 記述済み
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()
    }
}

既存メッセージの取得

Simulator Screen Shot - iPod touch (7th generation) - 2021-09-08 at 13.54.42.png

過去にやり取りされたメッセージは NCMB のChatクラスにあります。チャット画面が表示されたタイミングで、そのデータを取得します。データを受け取ったらメインスレッドで更新する必要があるので注意してください。メッセージはそのまま chat.messages に入れてしまえば、表示に反映されます。

// 記述してください(8/8)
// NCMBに保存されているメッセージを取得する関数
func getPastMessages() {
    // データ取得用のクエリオブジェクトを用意
    var query = NCMBQuery.getQuery(className: "Chat")
    // 並び順はcreateDateの昇順
    query.order = ["createDate"]
    // 20件取得
    query.limit = 20
    // 検索実行
    query.findInBackground(callback: { result in
        // 結果判定
        switch result {
        case let .success(ary):
            // 取得できた場合は結果をチャットメッセージとして反映
            DispatchQueue.main.async {
                chat.messages = ary
            }
            break
        case .failure(_): break // エラーの場合
        }
    })
}

反映をリアルタイムで行う

メッセージが追加された際に表示への反映をリアルタイムで行うため ChatScreenModelmessages@Published を付けておきます。

// 記述済み
final class ChatScreenModel: ObservableObject {
    private var webSocketTask: URLSessionWebSocketTask?
    @Published var messages: [NCMBObject] = []

これで過去のメッセージも表示できるようになります。

まとめ

今回はNCMBの次の機能を利用しました。

  • 会員管理
    • 匿名認証
  • データストア
    • Chatクラス
      • データの保存
      • データの検索

NCMBには他にもファイルストアやプッシュ通知機能などがあります。ぜひ他の機能も試してください。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?