ハンズオン開催
こちらの内容を元にオンラインハンズオンを開催します。1時間程度になりますので、ご興味があればぜひご参加ください。
NCMBのSwift SDKを使ってデモアプリを作ってみます。リアルタイム通信系は人気があるのですが、NCMBでは残念ながらWebSocketは使えません。そこで今回はPieSocketというWebSocketを提供するサービスと組み合わせて、Swift製のチャットアプリを作ってみます。
今回は画面の説明とSDKの導入までを進めます。
コードについて
今回のコードは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
ログイン状態に応じて表示を分けています。
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() {
let user = NCMBUser.currentUser
user?["displayName"] = name
_ = user?.save()
displayName = name
}
}
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で、キーを入力します。
そしてそのファイルを扱う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>) {
// 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と選択します。
出てきたダイアログでSwift SDKのGitリポジトリURLを入力します。GitHubのリポジトリでHTTPSとして取得できるもの、または下記URLになります。
https://github.com/NIFCLOUD-mbaas/ncmb_swift.git
バージョンは最新のものでかまいません。
後はFinishボタンを押せば完了です。
初期化
今回はSwiftUIを利用しています。ライフサイクルもSwiftUIです。
まずSDKをインポートします。
import SwiftUI
import NCMB
次に scenePhase
を追加します。
@main
struct ChatApp: App {
// 追加
@Environment(\.scenePhase) private var scenePhase
後は body 内で onChange
を使って初期化します。
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の匿名認証を有効にします。
NCMBUser.enableAutomaticUser()
認証状態の確認
次に認証状態を確認する checkAuth
関数を呼び出します。内容は次の通りです。 NCMBUser.currentUser
が nil の場合は認証されていないので、匿名認証を実行します。
func checkAuth() -> Void {
// 認証データがあれば処理は終了
if NCMBUser.currentUser != nil {
return;
}
// 匿名認証実行
_ = NCMBUser.automaticCurrentUser()
}
セッションの有効性チェック
認証されている場合でも、セッションの有効性は確認していません。ローカルにある認証データを復元している状態のためです。そこで、データストアに一度アクセスを行い、API通信の有効性を確認します。
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.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の初期化と匿名認証処理が完了になります。
まとめ
今回はチャットアプリの仕様と画面、NCMBの初期化までを解説しました。次はチャットメッセージの送信と、NCMBのデータストアへの保存について解説します。