Swift と WebSocket を使用してチャット サーバーを作成しながら、SwiftUI で非常に原始的なチャット アプリを作成します。上から下までスウィフトだよ、ベイビー!
このチュートリアルでは、かなり原始的ですが機能的なチャット アプリを作成します。アプリは iOS または macOS、あるいはその両方で実行できます。 SwiftUI の利点は、マルチプラットフォーム アプリを作成するのに必要な労力がいかに少ないかです。
もちろん、チャット アプリは、通信するサーバーがなければほとんど役に立ちません。したがって、WebSocket を利用して、非常に原始的なチャット サーバーも作成します。すべては Swift で構築され、マシン上でローカルに実行されます。
このチュートリアルは、SwiftUI を使用した iOS/macOS アプリの開発経験がすでにあることを前提としています。概念については説明を進めながら説明しますが、すべてを詳しく説明するわけではありません。言うまでもなく、入力して手順に従えば、このチュートリアルが終わるまでに、同じく作成したサーバーと通信する、機能するチャット アプリ (iOS および/または macOS 用) が完成します。また、サーバーサイドの Swift や WebSocket などの概念についても基本的に理解できるようになります。
興味がなければ、いつでも最後までスクロールして、最終的なソース コードをダウンロードできます。
つまり、非常にシンプルでプレーンな、特徴のないサーバーを作成することから始めます。サーバーを Swift パッケージとして構築し、Vapor Web フレームワークを依存関係として追加します。これは、わずか数行のコードで WebSocket サーバーをセットアップするのに役立ちます。
その後、フロントエンド チャット アプリの構築を開始します。基本的なことから始めて、機能 (および必需品) を 1 つずつ追加していきます。
ほとんどの時間はアプリの作業に費やされますが、新しい機能を追加する際にはサーバー コードとアプリ コードの間を行ったり来たりすることになります。
オプション
始めましょう!
Xcode 12 を開き、新しいプロジェクトを開始します ( [ファイル] > [新しいプロジェクト] )。 [マルチプラットフォーム]で[Swift パッケージ]を選択します。
パッケージには、「 ChatServer 」など、論理的な名前 (自明のこと) を付けます。その後、好きな場所に保存します。
スイフトパッケージ?
Swift でフレームワークまたはマルチプラットフォーム ソフトウェア (Linux など) を作成する場合は、Swift パッケージが推奨される方法です。これらは、他の Swift プロジェクトが簡単に使用できるモジュラー コードを作成するための公式ソリューションです。ただし、Swift パッケージは必ずしもモジュール型プロジェクトである必要はありません。他の Swift パッケージを依存関係として単に使用するスタンドアロンの実行可能ファイルにすることもできます (これが私たちが行っていることです)。
Swift パッケージには Xcode プロジェクト (
.xcodeproj
) が存在しないことに気付いたかもしれません。他のプロジェクトと同様に Xcode で Swift パッケージを開くには、Package.swift
ファイルを開くだけです。 Xcode は Swift パッケージを開いていることを認識し、プロジェクト構造全体を開きます。最初にすべての依存関係が自動的に取得されます。Swift パッケージと Swift Package Manager の詳細については、Swift の公式 Web サイトをご覧ください。
サーバーのセットアップという面倒な作業をすべて処理するために、Vapor Web フレームワークを使用します。 Vapor には、WebSocket サーバーを作成するために必要な機能がすべて付属しています。
ウェブソケット?
Web にサーバーとリアルタイムで通信できる機能を提供するために、WebSocket が作成されました。これは、クライアントとサーバー間の安全なリアルタイム (低帯域幅) 通信のための仕様がよく説明されています。例: マルチプレイヤー ゲームやチャット アプリ。会社での貴重な時間を使ってプレイしている、中毒性の高いブラウザ内マルチプレイヤー ゲームはありませんか?そう、WebSocket です。
ただし、リアルタイムのビデオ ストリーミングなどを実行したい場合は、別のソリューションを探すのが最善です。 ?
このチュートリアルでは iOS/macOS チャット アプリを作成していますが、作成しているサーバーは WebSocket を使用して他のプラットフォームと同様に簡単に通信できます。確かに: 必要に応じて、このチャット アプリの Android バージョンと Web バージョンを作成して、同じサーバーと通信し、すべてのプラットフォーム間の通信を可能にすることもできます。
蒸気?
インターネットは複雑な一連のチューブです。単純な HTTP リクエストに応答する場合でも、かなりの量のコードが必要になります。幸いなことに、この分野の専門家は、何十年もの間、さまざまなプログラミング言語で私たちのためにすべての困難な作業を行ってくれるオープンソース Web フレームワークを開発してきました。 Vapor もその 1 つで、Swift で書かれています。これにはすでにいくつかの WebSocket 機能が備わっており、まさに必要なものです。
ただし、Swift を利用した Web フレームワークは Vapor だけではありません。 Kitura と Perfect もよく知られたフレームワークです。ただし、Vapor の方が開発に積極的であることは間違いありません。
Xcode はデフォルトでPackage.swift
ファイルを開く必要があります。ここには、Swift パッケージの一般的な情報と要件が記載されています。
ただし、その前に、 Sources/ChatServer
フォルダーを調べてください。 ChatServer.swift
ファイルがあるはずです。これの名前をmain.swift
に変更する必要があります。それが完了したら、 Package.swift
に戻ります。
products:
の下で、次の値を削除します。
. library ( name : " ChatServer " , targets : [ " ChatServer " ] )
...それを次のように置き換えます。
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
結局のところ、私たちのサーバーはライブラリではありません。しかし、むしろスタンドアロンの実行可能ファイルです。また、サーバーの実行が想定されるプラットフォーム (および最小バージョン) も定義する必要があります。これはplatforms: [.macOS(v10_15)]
name: "ChatServer"
で追加することで実行できます。
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
これにより、Swift パッケージが Xcode で「実行可能」になるはずです。
さて、Vapor を依存関係として追加しましょう。 dependencies: []
(コメントアウトされたものが必要です) に次の行を追加します。
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
Package.swift
ファイルを保存すると、Xcode はバージョン4.0.0
以降の Vapor 依存関係の取得を自動的に開始します。そのすべての依存関係も同様です。
Xcode が処理を行っている間に、ファイルにもう 1 つの調整を行う必要があります。それは、ターゲットに依存関係を追加することです。 targets:
.target(name: "ChatServer", dependencies: [])
があります。その空の配列に以下を追加します。
. product ( name : " Vapor " , package : " vapor " )
それでおしまい。 Package.swift
が完成しました。 Swift パッケージを次のように説明しました。
最終的なPackage.swift
次のようになります。
// swift-tools-version:5.3
import PackageDescription
let package = Package (
name : " ChatServer " ,
platforms : [
. macOS ( . v10_15 ) ,
] ,
products : [
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
] ,
dependencies : [
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
] ,
targets : [
. target (
name : " ChatServer " ,
dependencies : [
. product ( name : " Vapor " , package : " vapor " )
] ) ,
]
)
さあ、いよいよその時が来ました…
Xcode で、 Sources/ChatServer/main.swift
を開き、そこにあるものをすべて削除します。それは私たちにとって無価値です。代わりに、 main.swift
次のようにします。
import Vapor
var env = try Environment . detect ( ) // 1
let app = Application ( env ) // 2
defer { // 3
app . shutdown ( )
}
app . webSocket ( " chat " ) { req , client in // 4
print ( " Connected: " , client )
}
try app . run ( ) // 5
?バム! Vapor を使用して (WebSocket) サーバーを起動するのに必要なのはこれだけです。それがどれほど楽だったかを見てください。
defer
を登録し、 .shutdown()
を呼び出します。これにより、プログラムの終了時にクリーンアップが実行されます。/chat
で受信 WebSocket 接続のリッスンを開始します。今
プログラムが正常に実行されると、アプリらしきものは何も表示されない場合があります。それは、サーバー ソフトウェアにはグラフィカル ユーザー インターフェイスが搭載されていないことが多いためです。しかし、安心してください。プログラムはバックグラウンドで生きており、車輪を回転させています。ただし、Xcode コンソールには次のメッセージが表示されるはずです。
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
これは、サーバーが受信リクエストを正常にリッスンできることを意味します。これで、接続を開始できる WebSocket サーバーができたので、これは素晴らしいことです。
私はあなたを信じません?
何らかの理由で、私がずっと凶悪な嘘ばかり吐き続けていると思うなら、自分でサーバーをテストしてみてください。
お気に入りのブラウザを開き、空のタブが表示されていることを確認します。 (Safari の場合は、最初に開発者モードを有効にする必要があります。)インスペクター(
Cmd
+Option
+I
) を開き、 Consoleに移動します。入力してくださいnew WebSocket ( 'ws://localhost:8080/chat' )そして「Return」を押します。次に、Xcode コンソールを見てみましょう。すべてがうまくいけば、
Connected: WebSocketKit.WebSocket
と表示されるはずです。????
サーバーにはローカル マシンからのみアクセスできます。これは、物理的な iPhone/iPad をサーバーに接続できないことを意味します。代わりに、次の手順でシミュレーターを使用してチャット アプリをテストします。
物理デバイス上でチャット アプリをテストするには、いくつかの (小さな) 追加手順を実行する必要があります。付録 A を参照してください。
バックエンドはまだ終わっていませんが、今度はフロントエンドに移ります。チャットアプリそのもの!
Xcode で新しいプロジェクトを作成します。今回は、 [マルチプラットフォーム]で[アプリ]を選択します。もう一度、アプリの美しい名前を選択して続行します。 (私はSwiftChatを選びました。同意します、完璧です?)
アプリは外部のサードパーティのフレームワークやライブラリに依存しません。実際、必要なものはすべて、 Foundation
、 Combine
、およびSwiftUI
(Xcode 12 以降) を介して入手できます。
早速チャット画面で作業を始めてみましょう。新しい Swift ファイルを作成し、 ChatScreen.swift
名前を付けます。 Swift File を選択するかSwiftUI Viewテンプレートを選択するかは関係ありません。関係なく、その中のすべてを削除します。
ChatScreen.swift
のスターター キットは次のとおりです。
import SwiftUI
struct ChatScreen : View {
@ State private var message = " "
var body : some View {
VStack {
// Chat history.
ScrollView { // 1
// Coming soon!
}
// Message field.
HStack {
TextField ( " Message " , text : $message ) // 2
. padding ( 10 )
. background ( Color . secondary . opacity ( 0.2 ) )
. cornerRadius ( 5 )
Button ( action : { } ) { // 3
Image ( systemName : " arrowshape.turn.up.right " )
. font ( . system ( size : 20 ) )
}
. padding ( )
. disabled ( message . isEmpty ) // 4
}
. padding ( )
}
}
}
ContentsView.swift
で、 Hello World をChatScreen()
に置き換えます。
struct ContentView : View {
var body : some View {
ChatScreen ( )
}
}
とりあえずは真っ白なキャンバス。
左:暗い外観のiPhone。右:軽やかな外観のiPad。
ここにあるもの:
別のデザインを選択したい場合は、そのままお進みください。 ?
次に、UI に関係しないロジック、つまり作成したばかりのサーバーに接続する作業を開始しましょう。
SwiftUIはCombineフレームワークと連携して、コード内で懸念の分離を簡単に実装するためのツールを開発者に提供します。 ObservableObject
プロトコルと@StateObject
(または@ObservedObject
) プロパティ ラッパーを使用すると、非 UI ロジック ( Business Logicと呼ばれる) を別の場所に実装できます。物事はこうあるべきだ!結局のところ、UI が考慮すべき唯一のことは、ユーザーにデータを表示し、ユーザー入力に反応することです。データがどこから来たのか、どのように操作されたのかは気にすべきではありません。
React のバックグラウンドを持つ私にとって、この贅沢は信じられないほどうらやましい限りです。
ソフトウェア アーキテクチャに関する記事や議論は何千もあります。おそらく、MVC、MVVM、VAPOR、クリーン アーキテクチャなどの概念について聞いたり読んだりしたことがあるでしょう。それらにはそれぞれ独自の主張と応用があります。
これらについての説明は、このチュートリアルの範囲外です。ただし、ビジネス ロジックと UI ロジックを結び付けるべきではないということで一般的に合意されています。
この概念は、 ChatScreenにも同様に当てはまります。 ChatScreen が考慮すべきことは、メッセージの表示とユーザー入力テキストの処理だけです。 ✌️We Bs Oc K eTs✌ については気にしませんし、気にする必要もありません。
新しい Swift ファイルを作成するか、 ChatScreen.swift
の下部に次のコードを記述することができます。あなたの選択です。どこに存在しても、 import
を忘れないように注意してください。
import Combine
import Foundation
final class ChatScreenModel : ObservableObject {
private var webSocketTask : URLSessionWebSocketTask ? // 1
// MARK: - Connection
func connect ( ) { // 2
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) ! // 3
webSocketTask = URLSession . shared . webSocketTask ( with : url ) // 4
webSocketTask ? . receive ( completionHandler : onReceive ) // 5
webSocketTask ? . resume ( ) // 6
}
func disconnect ( ) { // 7
webSocketTask ? . cancel ( with : . normalClosure , reason : nil ) // 8
}
private func onReceive ( incoming : Result < URLSessionWebSocketTask . Message , Error > ) {
// Nothing yet...
}
deinit { // 9
disconnect ( )
}
}
これには理解すべきことがたくさんあるかもしれないので、ゆっくりと見ていきましょう。
URLSessionWebSocketTask
プロパティに保存します。URLSessionWebSocketTask
オブジェクトは、WebSocket 接続を担当します。これらは、 FoundationフレームワークのURLSession
ファミリの常駐者です。127.0.0.1
またはlocalhost
使用します)。 Vapor アプリケーションのデフォルトのポートは8080
です。そして、WebSocket 接続へのリスナーを/chat
パスに配置します。URLSessionWebSocketTask
を作成し、インスタンスのプロパティに保存します。onReceive(incoming:)
メソッドが呼び出されます。これについては後で詳しく説明します。ChatScreenModel
がメモリから削除されるときに、正常に切断されるようにしてください。これは素晴らしいスタートです。これで、UI コードを乱雑にせずにすべての WebSocket ロジックを配置できる場所ができました。 ChatScreen
ChatScreenModel
と通信できるようにします。
ChatScreenModel
ChatScreen
の State オブジェクトとして追加します。
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
いつサーバーに接続すればよいですか?もちろん、実際に画面が表示されている場合です。 ChatScreen
のinit()
で.connect()
を呼び出したくなるかもしれません。これは危険なことです。実際、SwiftUI では、 View が表示されない場合でも初期化できるため、 init()
に何も配置しないようにする必要があります。 (たとえば、 LazyVStack
やNavigationLink(destination:)
などです。) 貴重な CPU サイクルを無駄にするのは残念です。したがって、すべてをonAppear
に延期しましょう。
onAppear
メソッドをChatScreen
に追加します。次に、そのメソッドをVStack
の.onAppear(perform:)
修飾子に追加して渡します。
struct ChatScreen : View {
// ...
private func onAppear ( ) {
model . connect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
}
}
無駄なスペース?
多くの人は、代わりにこれらのメソッドの内容をインラインで記述することを好みます。
. onAppear { model . connect ( ) }これは個人的な好みにすぎません。個人的には、これらのメソッドを個別に定義することを好みます。はい、より多くのスペースがかかります。しかし、それらは見つけやすく、再利用可能で、
body
(さらに)乱雑になるのを防ぎ、おそらく折りたたむのが簡単です。 ?
同様に、ビューが消えたときにも切断する必要があります。実装は一目瞭然ですが、念のため:
struct ChatScreen : View {
// ...
private func onDisappear ( ) {
model . disconnect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
. onDisappear ( perform : onDisappear )
}
}
WebSocket 接続を気にしなくなった場合は、必ず接続を閉じることが非常に重要です。 WebSocket 接続を (正常に) 閉じると、サーバーに通知され、メモリから接続を削除できます。サーバーのメモリ内にデッド接続や不明な接続が残ってはいけません。
ふー。私たちがこれまでに経験してきた道のりはかなりのものでした。試してみましょう。ChatScreen
が表示されると、サーバーの Xcode コンソールにConnected: WebSocketKit.WebSocket
メッセージが表示されます。そうでない場合は、手順をやり直してデバッグを開始してください。
もう一つ™️。また、ユーザーがアプリを閉じたとき (またはChatScreen
から離れたとき)、WebSocket 接続が閉じられるかどうかもテストする必要があります。サーバー プロジェクトのmain.swift
ファイルに戻ります。現在、WebSocket リスナーは次のようになります。
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
client
の.onClose
にハンドラーを追加し、単純なprint()
のみを実行します。
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
サーバーを再実行し、チャット アプリを起動します。アプリが接続されたら、アプリを閉じます(単にバックグラウンドに置くのではなく、実際に終了します)。サーバーの Xcode コンソールには、 Disconnected: WebSocketKit.WebSocket
と表示されるはずです。これは、WebSocket 接続が気にならなくなったときに実際に閉じられることを確認します。したがって、サーバーには、メモリ内に残っている切断された接続があってはなりません。
実際にサーバーに何かを送信する準備はできましたか?確かにそうですよ。でも、ちょっとだけブレーキをかけて考えてみましょう。椅子にもたれかかり、目的もなく、しかしどういうわけか意図的に天井を見つめます...
サーバーに正確に何を送信するのでしょうか?そして、同じくらい重要なことですが、サーバーから何を受け取るのでしょうか?
最初に「テキストだけでいいんじゃないの?」と思うかもしれませんが、半分は正しいでしょう。しかし、メッセージの時間についてはどうでしょうか?差出人の名前はどうなるのでしょうか?メッセージを他のメッセージから一意にするための識別子はどうすればよいでしょうか?ユーザーがユーザー名などを作成するためのものはまだありません。それでは、それは横に置いて、メッセージの送受信だけに集中しましょう。
アプリ側とサーバー側の両方でいくつかの調整を行う必要があります。サーバーから始めましょう。
サーバー プロジェクトのSources/ChatServer
にModels.swift
という名前の新しい Swift ファイルを作成します。次のコードをModels.swift
に貼り付け (または入力) します。
import Foundation
struct SubmittedChatMessage : Decodable { // 1
let message : String
}
struct ReceivingChatMessage : Encodable , Identifiable { // 2
let date = Date ( ) // 3
let id = UUID ( ) // 4
let message : String // 5
}
何が起こっているかは次のとおりです。
Decodable
プロトコルです。Encodable
プロトコルに準拠しています。ReceivingChatMessage
の初期化時に自動的に生成されます。サーバー側でdate
とid
どのように生成されているかに注目してください。これにより、サーバーが真実の情報源になります。サーバーは今何時かを知っています。日付がクライアント側で生成された場合、その日付は信頼できません。クライアントが将来の時計を設定している場合はどうなるでしょうか?サーバーに日付を生成させると、サーバーのクロックが時間の唯一の参照になります。
タイムゾーン?
Swift の
Date
オブジェクトは、絶対参照時間として常に 00:00:00 UTC 01-01-2001 を持ちます。Date
初期化するとき、または文字列にフォーマットするとき (たとえば、DateFormatter
を介して)、クライアントの地域性が自動的に考慮されます。クライアントのタイムゾーンに応じて時間を加算または減算します。
UUID?
普遍的に一意のIDは、識別子の許容値としてグローバルにみなされます。
また、クライアントが同じ一意の識別子を持つ複数のメッセージを送信することも望ましくありません。偶然か故意に悪意を持ってか。サーバーにこの識別子を生成させると、セキュリティがさらに強化され、エラーの原因が少なくなります。
それでは。サーバーはクライアントからメッセージを受信すると、それを他のすべてのクライアントに渡す必要があります。ただし、これは、接続されているすべてのクライアントを追跡する必要があることを意味します。
サーバープロジェクトのmain.swift
に戻ります。 app.webSocket("chat")
のすぐ上に次の宣言を置きます。
var clientConnections = Set < WebSocket > ( )
ここにクライアント接続を保存します。
しかし、待ってください...大きくて悪質なコンパイル エラーが発生するはずです。これは、 WebSocket
オブジェクトがデフォルトではHashable
プロトコルに準拠していないためです。ただし、心配する必要はありません。これは (安価ではありますが) 簡単に実装できます。 main.swift
の一番下に次のコードを追加します。
extension WebSocket : Hashable {
public static func == ( lhs : WebSocket , rhs : WebSocket ) -> Bool {
ObjectIdentifier ( lhs ) == ObjectIdentifier ( rhs )
}
public func hash ( into hasher : inout Hasher ) {
hasher . combine ( ObjectIdentifier ( self ) )
}
}
バダビンバダブーム。上記のコードは、メモリ アドレスを一意のプロパティとして使用するだけで、 class
Hashable
(および定義によりEquatable
にも準拠) に準拠させるための迅速かつ簡単な方法です。注: これはクラスに対してのみ機能します。構造体については、もう少し実践的な実装が必要になります。
さて、クライアントを追跡できるようになったので、 app.webSocket("chat")
のすべて (クロージャとその内容を含む) を次のコードに置き換えます。
app . webSocket ( " chat " ) { req , client in
clientConnections . insert ( client )
client . onClose . whenComplete { _ in
clientConnections . remove ( client )
}
}
クライアントが接続すると、そのクライアントをclientConnections
に保存します。クライアントが切断されたら、同じSet
から削除します。 EZPZ。
この章の最後のステップは、サーバーの心臓部を追加することです。client.onClose.whenComplete
全体の下に、まだapp.webSocket("chat")
クロージャ内に、次のコード スニペットを追加します。
client . onText { _ , text in // 1
do {
guard let data = text . data ( using : . utf8 ) else {
return
}
let incomingMessage = try JSONDecoder ( ) . decode ( SubmittedChatMessage . self , from : data ) // 2
let outgoingMessage = ReceivingChatMessage ( message : incomingMessage . message ) // 3
let json = try JSONEncoder ( ) . encode ( outgoingMessage ) // 4
guard let jsonString = String ( data : json , encoding : . utf8 ) else {
return
}
for connection in clientConnections {
connection . send ( jsonString ) // 5
}
}
catch {
print ( error ) // 6
}
}
もう一度、上から:
.onText
ハンドラーを接続されたクライアントにバインドします。ReceivingChatMessage
初期化します。ReceivingChatMessage
の日付と一意の識別子は自動的に生成されることに注意してください。ReceivingChatMessage
JSON 文字列 (つまりData
として) にエンコードします。なぜ送り返すのですか?
これは、メッセージが実際にクライアントから正常に受信されたことの確認として使用できます。アプリは、他のメッセージを受信するのと同じように、メッセージを返します。これにより、後で追加のコードを記述する必要がなくなります。
終わり!サーバーはメッセージを受信し、接続されている他のクライアントにメッセージを渡す準備ができています。サーバーを実行し、バックグラウンドでアイドル状態にして、アプリの操作を続けます。
サーバー用に作成したSubmittedChatMessage
構造体とReceivingChatMessage
構造体を覚えていますか?アプリにもそれらが必要です。新しい Swift ファイルを作成し、 Models.swift
という名前を付けます。実装をコピーして貼り付けることもできますが、少し変更する必要があります。
import Foundation
struct SubmittedChatMessage : Encodable {
let message : String
}
struct ReceivingChatMessage : Decodable , Identifiable {
let date : Date
let id : UUID
let message : String
}
Encodable
とDecodable
プロトコルがどのように交換されているかに注目してください。これには意味があります。アプリでは、 SubmittedChatMessage
のみをエンコードし、 ReceivingChatMessage
のみをデコードします。サーバーの逆。また、 date
とid
の自動初期化も削除しました。アプリにはこれらを生成する業務はありません。
さて、 ChatScreenModel
に戻ります (別のファイルにあるか、 ChatScreen.swift
の下部にあるかに関係なく)。上部を追加しますが、 ChatScreenModel
内に次のインスタンス プロパティを追加します。
@ Published private ( set ) var messages : [ ReceivingChatMessage ] = [ ]
ここに受信したメッセージを保存します。 @Published
のおかげで、 ChatScreen
この配列がいつ更新されるかを正確に認識し、この変更に反応します。 private(set)
ChatScreenModel
のみがこのプロパティを更新できるようにします。 (結局のところ、それはデータの所有者です。他のオブジェクトはそれを直接変更する必要はありません。)
引き続きChatScreenModel
内に、次のメソッドを追加します。
func send ( text : String ) {
let message = SubmittedChatMessage ( message : text ) // 1
guard let json = try ? JSONEncoder ( ) . encode ( message ) , // 2
let jsonString = String ( data : json , encoding : . utf8 )
else {
return
}
webSocketTask ? . send ( . string ( jsonString ) ) { error in // 3
if let error = error {
print ( " Error sending message " , error ) // 4
}
}
}
説明するまでもないでしょう。ただし、一貫性を保つために:
SubmittedChatMessage
。 ChatScreen.swift
を開き、次のメソッドをChatScreen
に追加します。
private func onCommit ( ) {
if !message . isEmpty {
model . send ( text : message )
message = " "
}
}
このメソッドは、ユーザーが送信ボタンを押したとき、またはキーボードの Return キーを押したときに呼び出されます。ただし、実際に何かが含まれている場合にのみメッセージが送信されます。
ChatScreen
の.body
でTextField
とButton
見つけて、それら (修飾子や内容ではなく) を次の初期化で置き換えます。
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
TextField
がフォーカスされているときに Return キーが押されると、 onCommit
呼び出されます。ユーザーがButton
を押したときも同様です。 TextField
はonEditingChanged
引数も必要ですが、空のクロージャを与えることでそれを破棄します。
今こそ、私たちが持っているものをテストし始める時です。サーバーがバックグラウンドでまだ実行されていることを確認してください。サーバーのmain.swift
内のclient.onText
クロージャー (サーバーが受信メッセージを読み取る場所) にいくつかのブレークポイントを配置します。アプリを実行してメッセージを送信します。 main.swift
内のブレークポイントは、アプリからメッセージを受信したときにヒットする必要があります。そうなった場合、?豊かな! ?そうでない場合は、手順をやり直してデバッグを開始してください。
メッセージを送るのはとてもかわいいです。しかし、それらを受け取る場合はどうでしょうか? (厳密に言えば、私たちはメッセージを受信していますが、決して反応しないだけです。) そのとおりです。
もう一度ChatScreenModel
にアクセスしてみましょう。 onReceive(incoming:)
メソッドを覚えていますか?これを置き換えて、以下に示すように兄弟メソッドを追加します。
private func onReceive ( incoming : Result < URLSessionWebSocketTask . Message , Error > ) {
webSocketTask ? . receive ( completionHandler : onReceive ) // 1
if case . success ( let message ) = incoming { // 2
onMessage ( message : message )
}
else if case . failure ( let error ) = incoming { // 3
print ( " Error " , error )
}
}
private func onMessage ( message : URLSessionWebSocketTask . Message ) { // 4
if case . string ( let text ) = message { // 5
guard let data = text . data ( using : . utf8 ) ,
let chatMessage = try ? JSONDecoder ( ) . decode ( ReceivingChatMessage . self , from : data )
else {
return
}
DispatchQueue . main . async { // 6
self . messages . append ( chatMessage )
}
}
}
それで...
URLSessionWebSocketTask
にバインドします。彼らは一度だけ働きます。したがって、新しいハンドラーを即座に再バインドし、次の受信メッセージを読み取る準備が整います。ReceivingChatMessage
にデコードします。self.messages
にプロップします。ただし、 URLSessionWebSocketTask
別のスレッドで受信ハンドラーを呼び出すことができ、SwiftUI はメイン スレッドでのみ動作するため、変更をDispatchQueue.main.async {}
でラップして、実際に変更を実行していることを確認する必要があります。メインスレッド。SwiftUI でさまざまなスレッドを操作する方法と理由の説明は、このチュートリアルの範囲を超えています。
もうすぐそこです!
ChatScreen.swift
に再度チェックインしてください。空のScrollView
が見えますか?最後にメッセージを入力できます。
ScrollView {
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
}
}
}
それは決して壮観に見えるわけではありません。しかし、今のところはこれで十分です。すべてのメッセージをプレーンなText
で表すだけです。
さあ、アプリを実行してください。メッセージを送信すると、すぐに画面に表示されます。これは、メッセージがサーバーに正常に送信され、サーバーがメッセージをアプリに正常に送り返したことを確認します。ここで、可能であれば、アプリの複数のインスタンスを開きます (ヒント: 異なるシミュレーターを使用します)。クライアントの数に事実上制限はありません。一人で楽しいおしゃべりパーティーを楽しんでください。
画面に空きがなくなるまでメッセージを送信し続けます。何か気づきましたか?ヤープ。新しいメッセージが画面の境界を越えると、 ScrollView
自動的に一番下までスクロールしません。 ?
入力...
サーバーはメッセージごとに一意の識別子を生成することに注意してください。ようやく有効活用できるようになりました!この信じられないほどの利益を得るには、待った甲斐があったと断言します。
ChatScreen
で、 ScrollView
次のような美しさに変えます。
ScrollView {
ScrollViewReader { proxy in // 1
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
. id ( message . id ) // 2
}
}
. onChange ( of : model . messages . count ) { _ in // 3
scrollToLastMessage ( proxy : proxy )
}
}
}
次に、次のメソッドを追加します。
private func scrollToLastMessage ( proxy : ScrollViewProxy ) {
if let lastMessage = model . messages . last { // 4
withAnimation ( . easeOut ( duration : 0.4 ) ) {
proxy . scrollTo ( lastMessage . id , anchor : . bottom ) // 5
}
}
}
ScrollView
のコンテンツをScrollViewReader
でラップしています。ScrollViewReader
すぐに必要になるproxy
を提供します。model.messages.count
への変更を追跡します。この値が変更されると、追加したばかりのメソッドを呼び出し、 ScrollViewReader
によって提供されるproxy
を渡します。ScrollViewProxy
の.scrollTo(_:anchor:)
メソッドを呼び出します。これにより、 ScrollView
指定された識別子のビューまでスクロールするように指示されます。これをwithAnimation {}
でラップして、スクロールをアニメーション化します。さあ...
これらのメッセージはかなり豪華です...しかし、メッセージの送信者がわかって、受信メッセージと送信メッセージを視覚的に区別できれば、さらに豪華になるでしょう。
各メッセージには、ユーザー名とユーザー識別子も添付されます。ユーザー名だけではユーザーを識別できないため、一意のものが必要です。ユーザーと他の全員の名前が Patrick だったらどうなるでしょうか?私たちはアイデンティティの危機に陥り、パトリックが送信したメッセージとパトリックが受信したメッセージを区別できなくなります。
伝統的に、サーバーから始めますが、これは作業量が最小限です。
SubmittedChatMessage
とReceivingChatMessage
両方を定義したModels.swift
を開きます。これらの悪い奴らの両方に、次のようにuser: String
とuserID: UUID
プロパティを与えます。
struct SubmittedChatMessage : Decodable {
let message : String
let user : String // <- We
let userID : UUID // <- are
}
struct ReceivingChatMessage : Encodable , Identifiable {
let date = Date ( )
let id = UUID ( )
let message : String
let user : String // <- new
let userID : UUID // <- here
}
(アプリのプロジェクト内の Models.swift ファイルも忘れずに更新してください。)
main.swift
に戻り、エラーが表示されるはずですが、 ReceivingChatMessage
の初期化を次のように変更します。
let outgoingMessage = ReceivingChatMessage (
message : incomingMessage . message ,
user : incomingMessage . user ,
userID : incomingMessage . userID
)
それで終わりです!サーバーの準備は完了です。ここからはアプリだけです。ホームストレッチ!
アプリの Xcode プロジェクトで、 UserInfo.swift
という名前の新しい Swift ファイルを作成します。そこに次のコードを配置します。
import Combine
import Foundation
class UserInfo : ObservableObject {
let userID = UUID ( )
@ Published var username = " "
}
これは、ユーザー名を保存できるEnvironmentObject
になります。いつものように、一意の識別子は自動的に生成された不変のUUIDです。ユーザー名はどこから来たのでしょうか?ユーザーは、アプリを開くときにチャット画面が表示される前にこれを入力します。
新しいファイル時間: SettingsScreen.swift
。このファイルには、簡単な設定フォームが含まれます。
import SwiftUI
struct SettingsScreen : View {
@ EnvironmentObject private var userInfo : UserInfo // 1
private var isUsernameValid : Bool {
!userInfo . username . trimmingCharacters ( in : . whitespaces ) . isEmpty // 2
}
var body : some View {
Form {
Section ( header : Text ( " Username " ) ) {
TextField ( " E.g. John Applesheed " , text : $userInfo . username ) // 3
NavigationLink ( " Continue " , destination : ChatScreen ( ) ) // 4
. disabled ( !isUsernameValid )
}
}
. navigationTitle ( " Settings " )
}
}
UserInfo
クラスに、 EnvironmentObject
としてアクセスできます。TextField
その内容をuserInfo.username
に直接書き込みます。ChatScreen
表示するNavigationLink
。ユーザー名が無効な場合、ボタンは無効になります。 ( NavigationLink
でChatScreen
どのように初期化しているかわかりますか? ChatScreen
init()
でサーバーに接続させていたら、今すぐに接続していたはずです!)必要に応じて、画面に少し華やかさを追加できます。
SwiftUI のナビゲーション機能を使用しているため、どこかでNavigationView
から始める必要があります。 ContentView
これに最適な場所です。 ContentView
の実装を次のように変更します。
struct ContentView : View {
@ StateObject private var userInfo = UserInfo ( ) // 1
var body : some View {
NavigationView {
SettingsScreen ( )
}
. environmentObject ( userInfo ) // 2
. navigationViewStyle ( StackNavigationViewStyle ( ) ) // 3
}
}
UserInfo
のインスタンスを初期化して...EnvironmentObject
として渡し、後続のすべてのビューからアクセスできるようにします。次に、サーバーに送信するメッセージとともにUserInfo
のデータを送信します。 ChatScreenModel
(どこに置いても) に移動します。クラスの先頭に次のプロパティを追加します。
final class ChatScreenModel : ObservableObject {
private var username : String ?
private var userID : UUID ?
// the rest ...
}
ChatModelScreen
接続時にこれらの値を受け取る必要があります。この情報の出所を知るのはChatModelScreen
の仕事ではありません。将来、 username
とuserID
両方の保存場所を変更する場合は、 ChatModelScreen
そのままにしておくことができます。
これらの新しいプロパティを引数として受け入れるようにconnect()
メソッドを変更します。
func connect ( username : String , userID : UUID ) {
self . username = username
self . userID = userID
// etc ...
}
最後に、 send(text:)
で、サーバーに送信するSubmittedChatMessage
にこれらの新しい値を適用する必要があります。
func send ( text : String ) {
guard let username = username , let userID = userID else { // Safety first!
return
}
let message = SubmittedChatMessage ( message : text , user : username , userID : userID )
// Everything else ...
}
ああ、 ChatScreenModel
についてはこれで終わりです。終わりました。 ??
最後に、 ChatScreen.swift
開きます。 ChatScreen
の上部に次を追加します。
@ EnvironmentObject private var userInfo : UserInfo
ビューが表示されたら、 username
とuserID
ChatScreenModel
に指定することを忘れないでください。
private func onAppear ( ) {
model . connect ( username : userInfo . username , userID : userInfo . userID )
}
さて、もう一度、練習通りに、椅子にもたれかかり、天井を見上げてください。テキスト メッセージはどのように見えるべきですか?創造的な思考をする気分ではない場合は、単一の受信 (および送信) メッセージを表す次のビューを使用できます。
struct ChatMessageRow : View {
static private let dateFormatter : DateFormatter = {
let formatter = DateFormatter ( )
formatter . dateStyle = . none
formatter . timeStyle = . short
return formatter
} ( )
let message : ReceivingChatMessage
let isUser : Bool
var body : some View {
HStack {
if isUser {
Spacer ( )
}
VStack ( alignment : . leading , spacing : 6 ) {
HStack {
Text ( message . user )
. fontWeight ( . bold )
. font ( . system ( size : 12 ) )
Text ( Self . dateFormatter . string ( from : message . date ) )
. font ( . system ( size : 10 ) )
. opacity ( 0.7 )
}
Text ( message . message )
}
. foregroundColor ( isUser ? . white : . black )
. padding ( 10 )
. background ( isUser ? Color . blue : Color ( white : 0.95 ) )
. cornerRadius ( 5 )
if !isUser {
Spacer ( )
}
}
}
}
見た目は特に刺激的ではありません。 iPhone では次のようになります。
(サーバーがメッセージの日付も送信する方法を覚えていますか? ここでは時刻を表示するために使用されています。)
色と位置は、親から渡されたisUser
プロパティに基づきます。この場合、その親は他でもないChatScreen
です。 ChatScreen
メッセージだけでなくUserInfo
にもアクセスできるため、メッセージがユーザーに属するかどうかを判断するロジックが配置されます。
ChatMessageRow
以前にメッセージを表すために使用していた退屈なText
置き換えます。
ScrollView {
ScrollViewReader { proxy in
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
// This one right here ?, officer.
ChatMessageRow ( message : message , isUser : message . userID == userInfo . userID )
. id ( message . id )
}
}
// etc.
}
}
フィニッシュラインへようこそ!ここまでよく頑張ってくれましたね!最後に、
これまでに、原始的ではあるが機能するチャット アプリが完成しているはずです。送受信メッセージを処理するサーバーも同様です。すべてSwiftで書かれています!
おめでとうございます!そして読んでいただき本当にありがとうございます! ?
最終コードは Github からダウンロードできます。
左: ダークな外観の小さな iPad。右: 軽やかな外観の巨大な iPhone。
私たちの旅を要約しましょう:
それはすべて、Swift エコシステム内に完全に留まりながら行われます。追加のプログラミング言語、ココアポッドなどはありません。
もちろん、ここで作成したのは、完全な制作対応チャットアプリとサーバーのほんの一部にすぎません。時間と複雑さを節約するために、たくさんのコーナーを切りました。言うまでもなく、チャットアプリがどのように機能するかをかなり基本的に理解する必要があります。
おそらく、自分自身を実装するには、次の機能を検討してください。
ForEach
ビューを使用して、メモリ内のすべてのメッセージを反復します。最新のチャットソフトウェアは、レンダリングするための少数のメッセージのみを追跡し、ユーザーがスクロールアップしたら古いメッセージにのみロードします。その奇妙なurlsessionwebsockettask API
以前にWebSocketsを使用したことがある場合は、WebSocketのAppleのAPIは非常に...非伝統的であるという意見を共有できます。あなたは確かにこれについて一人ではありません。受信ハンドラーを絶えず逆転させなければならないのは奇妙です。 iOSとmacOSのより伝統的なWebSocketAPIをより快適に使用する場合は、Starscreamをお勧めします。それはよくテストされ、パフォーマンスがあり、iOSの古いバージョンで動作します。
バグバグバグ
このチュートリアルは、Xcode 12ベータ5およびiOS 14ベータ5を使用して記述されました。新しいベータバージョンごとにバグが表示され、消えます。残念ながら、将来(ベータ)リリースで何が機能しないかを予測することは不可能です。
サーバーはローカルマシンで実行されるだけでなく、ローカルマシンからのみアクセスできます。これは、Simulatorでアプリを実行する場合(または同じマシンのMacOSアプリとして)問題ではありません。ただし、物理デバイスまたは別のMacでアプリを実行すると、サーバーにローカルネットワークでアクセスできるようにする必要があります。
これを行うには、サーバーコードのmain.swift
で、 Application
インスタンスを初期化した後、次の行を直接追加します。
app . http . server . configuration . hostname = " 0.0.0.0 "
ChatScreenModel
では、 connect(username:userID:)
メソッドで、マシンのローカルIPに合わせてURLを変更する必要があります。
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
マシンのローカルIPはさまざまな方法で見つけることができます。個人的には、システムの設定>ネットワークを常に開き、IPが直接表示され、選択してコピーできます。
これの成功率はネットワーク間で異なることに注意する必要があります。これが機能するのを防ぐことができる多くの要因(セキュリティなど)があります。
読んでいただきありがとうございます!この記事について意見がある場合は、改善の考え、またはいくつかのエラーを見つけた場合は、お願いします。教えてください!このチュートリアルを継続的に改善するために最善を尽くします。 ?