ハンズオン開催
こちらの内容を元にオンラインハンズオンを開催します。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
チャット用の表示名を設定する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です。
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")!)¬ify_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と選択します。
出てきたダイアログでSwift SDKのGitリポジトリURLを入力します。GitHubのリポジトリでHTTPSとして取得できるもの、または下記URLになります。
https://github.com/NIFCLOUD-mbaas/ncmb_swift.git
バージョンは最新のものでかまいません。
後はFinishボタンを押せば完了です。
初期化(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の管理画面でアプリ設定を開き、匿名認証を有効にします。
これで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の接続やメッセージの送受信を管理している ChatScreenModel
を ObservedObject
として定義しています。
// 記述済み
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()
}
}
既存メッセージの取得
過去にやり取りされたメッセージは 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 // エラーの場合
}
})
}
反映をリアルタイムで行う
メッセージが追加された際に表示への反映をリアルタイムで行うため ChatScreenModel
の messages
に @Published
を付けておきます。
// 記述済み
final class ChatScreenModel: ObservableObject {
private var webSocketTask: URLSessionWebSocketTask?
@Published var messages: [NCMBObject] = []
これで過去のメッセージも表示できるようになります。
まとめ
今回はNCMBの次の機能を利用しました。
- 会員管理
- 匿名認証
- データストア
- Chatクラス
- データの保存
- データの検索
- Chatクラス
NCMBには他にもファイルストアやプッシュ通知機能などがあります。ぜひ他の機能も試してください。