Membuat aplikasi obrolan yang sangat primitif di SwiftUI, sambil menggunakan Swift dan WebSockets untuk membuat server obrolan. Ini Swift dari atas ke bawah, bay-bee!
Dalam tutorial ini kita akan membuat aplikasi chat yang agak primitif, namun fungsional. Aplikasi ini akan berjalan di iOS atau macOS - atau keduanya! Keindahan SwiftUI adalah betapa sedikitnya usaha yang diperlukan untuk membuat aplikasi multiplatform.
Tentu saja, aplikasi obrolan tidak akan banyak berguna tanpa server untuk diajak bicara. Oleh karena itu kami juga akan membuat server obrolan yang sangat primitif, menggunakan WebSockets. Semuanya akan dibangun di Swift dan dijalankan secara lokal di mesin Anda.
Tutorial ini mengasumsikan Anda sudah memiliki sedikit pengalaman mengembangkan aplikasi iOS/macOS menggunakan SwiftUI. Meskipun konsepnya akan dijelaskan seiring berjalannya waktu, tidak semuanya akan dibahas secara mendalam. Tentu saja, jika Anda mengetik dan mengikuti langkah-langkahnya, di akhir tutorial ini Anda akan memiliki aplikasi obrolan yang berfungsi (untuk iOS dan/atau macOS), yang berkomunikasi dengan server yang juga Anda buat! Anda juga akan memiliki pemahaman dasar tentang konsep seperti Swift sisi server dan WebSockets.
Jika tidak ada yang menarik minat Anda, Anda selalu dapat menggulir ke akhir dan mengunduh kode sumber terakhir!
Singkatnya, kita akan mulai dengan membuat server yang sangat sederhana, sederhana, dan tanpa fitur. Kami akan membangun server sebagai Paket Swift, lalu menambahkan kerangka web Vapor sebagai ketergantungan. Ini akan membantu kami menyiapkan server WebSocket hanya dengan beberapa baris kode.
Setelah itu kita akan mulai membangun aplikasi chat frontend. Memulai dengan cepat dari dasar-dasarnya, lalu menambahkan fitur (dan kebutuhan) satu per satu.
Sebagian besar waktu kita akan dihabiskan untuk mengerjakan aplikasi, namun kita akan bolak-balik antara kode server dan kode aplikasi saat kita menambahkan fitur baru.
Opsional
Mari kita mulai!
Buka Xcode 12 dan mulai proyek baru ( File > New Project ). Di bawah Multiplatform pilih Paket Swift .
Sebut Paket itu sesuatu yang logis - sesuatu yang cukup jelas - seperti " ChatServer ". Kemudian simpan di mana pun Anda suka.
Paket Cepat?
Saat membuat kerangka kerja atau perangkat lunak multiplatform (misalnya Linux) di Swift, Paket Swift adalah cara yang lebih disukai. Ini adalah solusi resmi untuk membuat kode modular yang dapat digunakan dengan mudah oleh proyek Swift lainnya. Paket Swift tidak harus berupa proyek modular: paket ini juga bisa berupa executable mandiri yang hanya menggunakan Paket Swift lain sebagai dependensi (yang sedang kami lakukan).
Mungkin terpikir oleh Anda bahwa tidak ada proyek Xcode (
.xcodeproj
) untuk Paket Swift. Untuk membuka Paket Swift di Xcode seperti proyek lainnya, cukup buka filePackage.swift
. Xcode akan mengenali Anda sedang membuka Paket Swift dan membuka seluruh struktur proyek. Ini akan secara otomatis mengambil semua dependensi di awal.Anda dapat membaca lebih lanjut tentang Paket Swift dan Manajer Paket Swift di situs resmi Swift.
Untuk menangani semua pekerjaan berat dalam menyiapkan server, kami akan menggunakan kerangka web Vapor. Vapor hadir dengan semua fitur yang diperlukan untuk membuat server WebSocket.
Soket Web?
Untuk memberikan web kemampuan berkomunikasi dengan server secara realtime, WebSockets dibuat. Ini adalah spesifikasi yang dijelaskan dengan baik untuk komunikasi realtime (bandwidth rendah) yang aman antara klien dan server. Misalnya: game multipemain dan aplikasi obrolan. Game multipemain dalam browser yang membuat ketagihan yang pernah Anda mainkan di waktu bersama yang berharga? Ya, WebSockets!
Namun, jika Anda ingin melakukan sesuatu seperti streaming video waktu nyata, sebaiknya Anda mencari solusi lain. ?
Meskipun kami membuat aplikasi obrolan iOS/macOS dalam tutorial ini, server yang kami buat dapat dengan mudah berkomunikasi dengan platform lain dengan WebSockets. Memang benar: jika mau, Anda juga dapat membuat versi Android dan web dari aplikasi obrolan ini, berbicara ke server yang sama dan memungkinkan komunikasi antar semua platform!
Menguap?
Internet adalah serangkaian tabung yang kompleks. Bahkan menanggapi permintaan HTTP sederhana memerlukan sejumlah kode yang serius. Untungnya, para ahli di bidangnya telah mengembangkan kerangka web sumber terbuka yang melakukan semua kerja keras kita selama beberapa dekade, dalam berbagai bahasa pemrograman. Vapor adalah salah satunya, dan ditulis dalam Swift. Itu sudah dilengkapi dengan beberapa kemampuan WebSocket dan itulah yang kami butuhkan.
Vapor bukan satu-satunya kerangka web yang didukung Swift. Kitura dan Perfect juga merupakan kerangka kerja yang terkenal. Padahal Vapor bisa dibilang lebih aktif dalam pengembangannya.
Xcode harus membuka file Package.swift
secara default. Di sinilah kami meletakkan informasi umum dan persyaratan Paket Swift kami.
Sebelum kita melakukannya, lihat di folder Sources/ChatServer
. Itu harus memiliki file ChatServer.swift
. Kita perlu mengganti namanya menjadi main.swift
. Setelah selesai, kembali ke Package.swift
.
Di bawah products:
, hapus nilai berikut:
. library ( name : " ChatServer " , targets : [ " ChatServer " ] )
... dan ganti dengan:
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
Bagaimanapun, server kami bukanlah Perpustakaan. Melainkan merupakan executable yang berdiri sendiri. Kita juga harus menentukan platform (dan versi minimum) yang kita harapkan untuk menjalankan server kita. Hal ini dapat dilakukan dengan menambahkan platforms: [.macOS(v10_15)]
di bawah name: "ChatServer"
:
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
Semua ini akan membuat Paket Swift kita 'dapat dijalankan' di Xcode.
Baiklah, mari tambahkan Vapor sebagai ketergantungan. Dalam dependencies: []
(yang seharusnya memiliki beberapa hal yang dikomentari), tambahkan yang berikut:
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
Saat menyimpan file Package.swift
, Xcode akan mulai mengambil dependensi Vapor secara otomatis dengan versi 4.0.0
atau lebih baru. Serta semua ketergantungannya .
Kita hanya perlu membuat satu penyesuaian lagi pada file sementara Xcode melakukan tugasnya: menambahkan ketergantungan pada target kita. Dalam targets:
Anda akan menemukan .target(name: "ChatServer", dependencies: [])
. Dalam array kosong itu, tambahkan yang berikut ini:
. product ( name : " Vapor " , package : " vapor " )
Itu saja . Package.swift
kami.swift selesai. Kami telah mendeskripsikan Paket Swift kami dengan memberitahunya:
Package.swift
terakhir akan terlihat seperti ini (-ish):
// 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 " )
] ) ,
]
)
Sekarang, akhirnya tiba waktunya untuk...
Di Xcode, buka Sources/ChatServer/main.swift
dan hapus semua yang ada di sana. Itu tidak ada gunanya bagi kami. Sebagai gantinya, buat main.swift
terlihat seperti ini:
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
? Bam! Hanya itu yang diperlukan untuk memulai server (WebSocket) menggunakan Vapor. Lihatlah betapa mudahnya hal itu.
defer
dan panggil .shutdown()
yang akan melakukan pembersihan apa pun saat keluar dari program./chat
. Sekarang
Setelah program berhasil dijalankan, Anda mungkin tidak melihat apa pun yang menyerupai aplikasi. Itu karena perangkat lunak server cenderung tidak memiliki antarmuka pengguna grafis. Namun yakinlah, program ini masih hidup dan berjalan dengan baik di latar belakang, terus berputar. Konsol Xcode akan menampilkan pesan berikut:
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
Ini berarti server berhasil mendengarkan permintaan masuk. Ini bagus, karena sekarang kita memiliki server WebSocket yang dapat kita sambungkan!
Aku tidak percaya padamu?
Jika karena alasan apa pun Anda berpikir saya tidak mengeluarkan apa pun selain kebohongan keji selama ini, Anda dapat menguji sendiri servernya!
Buka browser favorit Anda dan pastikan Anda berada di tab kosong. (Jika itu Safari, Anda harus mengaktifkan mode Pengembang terlebih dahulu.) Buka Inspektur (
Cmd
+Option
+I
) dan buka Console . Ketiknew WebSocket ( 'ws://localhost:8080/chat' )dan tekan Kembali. Sekarang lihat konsol Xcode. Jika semuanya berjalan dengan baik, sekarang
Connected: WebSocketKit.WebSocket
akan ditampilkan.????
Server hanya dapat diakses dari mesin lokal Anda. Ini berarti Anda tidak dapat menghubungkan iPhone/iPad fisik Anda ke server. Sebagai gantinya, kami akan menggunakan Simulator pada langkah-langkah berikut untuk menguji aplikasi obrolan kami.
Untuk menguji aplikasi obrolan pada perangkat fisik, beberapa langkah tambahan (kecil) perlu dilakukan. Lihat Lampiran A.
Meskipun kita belum selesai dengan backend, sekarang saatnya beralih ke frontend. Aplikasi obrolan itu sendiri!
Di Xcode buat proyek baru. Kali ini, di bawah Multiplatform pilih App . Sekali lagi, pilih nama yang indah untuk aplikasi Anda dan lanjutkan. (Saya memilih SwiftChat . Saya setuju, ini sempurna ?)
Aplikasi ini tidak bergantung pada kerangka kerja atau perpustakaan pihak ketiga eksternal. Memang semua yang kita butuhkan tersedia melalui Foundation
, Combine
dan SwiftUI
(dalam Xcode 12+).
Mari segera mulai mengerjakan layar obrolan. Buat file Swift baru dan beri nama ChatScreen.swift
. Tidak masalah apakah Anda memilih Swift File atau template SwiftUI View . Kami akan menghapus semua yang ada di dalamnya.
Inilah starter kit dari 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 ( )
}
}
}
Di ContentsView.swift
, ganti Hello World dengan ChatScreen()
:
struct ContentView : View {
var body : some View {
ChatScreen ( )
}
}
Kanvas kosong, untuk saat ini.
Kiri: iPhone dengan tampilan gelap. Kanan: iPad dengan tampilan ringan.
Apa yang kami miliki di sini:
Jika Anda ingin membuat pilihan desain yang berbeda, silakan saja. ?
Sekarang mari kita mulai mengerjakan beberapa logika yang tidak terkait dengan UI: menghubungkan ke server yang baru saja kita buat.
SwiftUI , bersama dengan kerangka Combine , memberi pengembang alat untuk mengimplementasikan Pemisahan Kekhawatiran dengan mudah dalam kode mereka. Dengan menggunakan protokol ObservableObject
dan pembungkus properti @StateObject
(atau @ObservedObject
) kita dapat mengimplementasikan logika non-UI (disebut sebagai Business Logic ) di tempat terpisah. Sebagaimana seharusnya! Lagi pula, satu-satunya hal yang harus diperhatikan oleh UI adalah menampilkan data kepada pengguna dan bereaksi terhadap masukan pengguna. Ia tidak peduli dari mana data tersebut berasal, atau bagaimana data tersebut dimanipulasi.
Berasal dari latar belakang React, kemewahan ini adalah sesuatu yang sangat membuat saya iri.
Ada ribuan artikel dan diskusi tentang arsitektur perangkat lunak. Anda mungkin pernah mendengar atau membaca tentang konsep seperti MVC, MVVM, VAPOR, Clean Architecture, dan banyak lagi. Mereka semua mempunyai argumen dan penerapannya masing-masing.
Membahas hal ini di luar cakupan tutorial ini. Namun secara umum disepakati bahwa logika bisnis dan logika UI tidak boleh saling terkait.
Konsep ini juga berlaku untuk ChatScreen kami. Satu-satunya hal yang harus diperhatikan oleh ChatScreen adalah menampilkan pesan dan menangani teks input pengguna. Ia tidak peduli dengan ✌️We Bs Oc K eTs✌, dan seharusnya tidak demikian.
Anda dapat membuat file Swift baru atau menulis kode berikut di bagian bawah ChatScreen.swift
. Pilihanmu. Dimanapun ia tinggal, pastikan Anda tidak melupakan import
s!
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 ( )
}
}
Ini mungkin banyak hal yang perlu dipahami, jadi mari kita bahas secara perlahan:
URLSessionWebSocketTask
di sebuah properti.URLSessionWebSocketTask
bertanggung jawab atas koneksi WebSocket. Mereka adalah penghuni keluarga URLSession
dalam kerangka Foundation .127.0.0.1
atau localhost
). Port default aplikasi Vapor adalah 8080
. Dan kami menempatkan pendengar koneksi WebSocket di jalur /chat
.URLSessionWebSocketTask
dan menyimpannya di properti instance.onReceive(incoming:)
akan dipanggil. Lebih lanjut tentang ini nanti.ChatScreenModel
dihapus dari memori. Ini adalah awal yang baik. Kami sekarang memiliki tempat di mana kami dapat meletakkan semua logika WebSocket tanpa mengacaukan kode UI. Saatnya ChatScreen
berkomunikasi dengan ChatScreenModel
.
Tambahkan ChatScreenModel
sebagai Objek Status di ChatScreen
:
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
Kapan kita harus terhubung ke server? Nah, ketika layarnya benar-benar terlihat tentunya. Anda mungkin tergoda untuk memanggil .connect()
di init()
dari ChatScreen
. Ini adalah hal yang berbahaya. Faktanya, di SwiftUI seseorang harus mencoba untuk menghindari memasukkan apa pun init()
, karena Tampilan dapat diinisialisasi meskipun tidak akan pernah muncul. (Misalnya di LazyVStack
atau di NavigationLink(destination:)
.) Akan sangat disayangkan jika menyia-nyiakan siklus CPU yang berharga. Oleh karena itu, mari tunda semuanya onAppear
.
Tambahkan metode onAppear
ke ChatScreen
. Kemudian tambahkan dan teruskan metode itu ke pengubah .onAppear(perform:)
dari VStack
:
struct ChatScreen : View {
// ...
private func onAppear ( ) {
model . connect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
}
}
Ruang terbuang?
Banyak orang lebih suka menulis konten metode ini secara inline:
. onAppear { model . connect ( ) }Ini hanyalah preferensi pribadi. Secara pribadi saya ingin mendefinisikan metode ini secara terpisah. Ya, biayanya lebih banyak ruang. Namun lebih mudah ditemukan, dapat digunakan kembali, mencegah
body
menjadi (lebih) berantakan, dan bisa dibilang lebih mudah dilipat. ?
Dengan cara yang sama, kita juga harus memutuskan sambungan ketika tampilan menghilang. Penerapannya harus cukup jelas, namun untuk berjaga-jaga:
struct ChatScreen : View {
// ...
private func onDisappear ( ) {
model . disconnect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
. onDisappear ( perform : onDisappear )
}
}
Sangat penting untuk menutup koneksi WebSocket setiap kali kita berhenti mempedulikannya. Saat Anda (dengan anggun) menutup koneksi WebSocket, server akan diberi tahu dan dapat menghapus koneksi dari memori. Server tidak boleh memiliki koneksi mati atau koneksi tidak dikenal yang tertinggal di memori.
Fiuh. Cukup banyak perjalanan yang telah kami lalui sejauh ini. Saatnya untuk mengujinya.ChatScreen
, Anda akan melihat pesan Connected: WebSocketKit.WebSocket
di konsol Xcode server. Jika tidak, telusuri kembali langkah Anda dan mulai melakukan debug!
Satu hal lagi™️. Kita juga harus menguji apakah koneksi WebSocket ditutup ketika pengguna menutup aplikasi (atau keluar dari ChatScreen
). Kembali ke file main.swift
proyek server. Saat ini pendengar WebSocket kami terlihat seperti ini:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
Tambahkan handler ke .onClose
dari client
, dengan hanya melakukan print()
:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
Jalankan kembali server dan mulai aplikasi obrolan. Setelah aplikasi terhubung, tutup aplikasi (keluar sebenarnya, jangan hanya meletakkannya di latar belakang). Konsol Xcode server sekarang harus mencetak Disconnected: WebSocketKit.WebSocket
. Ini menegaskan bahwa koneksi WebSocket memang ditutup ketika kita tidak lagi mempedulikannya. Oleh karena itu, server seharusnya tidak memiliki koneksi mati yang tertinggal di memori.
Anda siap mengirim sesuatu ke server? Wah, saya yakin begitu. Namun untuk sesaat, mari kita mengerem dan berpikir sejenak. Bersandar di kursi dan menatap tanpa tujuan, namun entah bagaimana dengan sengaja ke langit-langit...
Apa sebenarnya yang akan kita kirimkan ke server? Dan, yang sama pentingnya, apa yang akan kita terima dari server?
Pikiran pertama Anda mungkin adalah "Yah, kirim pesan saja, kan?", Anda setengah benar. Tapi bagaimana dengan waktu pesannya? Bagaimana dengan nama pengirimnya? Bagaimana dengan pengidentifikasi untuk membuat pesan unik dari pesan lainnya? Kami belum memiliki apa pun bagi pengguna untuk membuat nama pengguna atau apa pun. Jadi mari kita kesampingkan hal itu dan fokus saja pada pengiriman dan penerimaan pesan.
Kami harus melakukan beberapa penyesuaian pada sisi aplikasi dan server. Mari kita mulai dengan servernya.
Buat file Swift baru di Sources/ChatServer
bernama Models.swift
di proyek server. Rekatkan (atau ketik) kode berikut ke 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
}
Inilah yang terjadi:
Decodable
.Encodable
.ReceivingChatMessage
. Perhatikan bagaimana kami membuat date
dan id
di sisi server. Hal ini menjadikan server sebagai Sumber Kebenaran. Server tahu jam berapa sekarang. Jika tanggal dibuat di sisi klien, tanggal tersebut tidak dapat dipercaya. Bagaimana jika klien memiliki pengaturan jam di masa depan? Meminta server menghasilkan tanggal menjadikan jamnya satu-satunya referensi waktu.
Zona waktu?
Objek
Date
Swift selalu memiliki 00:00:00 UTC 01-01-2001 sebagai waktu referensi absolut. Saat menginisialisasiDate
atau memformatnya menjadi string (misalnya melaluiDateFormatter
), lokalitas klien akan dipertimbangkan secara otomatis. Menambah atau mengurangi jam tergantung pada zona waktu klien.
UUID?
Secara umum, pengidentifikasi I unik secara global dianggap sebagai nilai pengidentifikasi yang dapat diterima.
Kami juga tidak ingin klien mengirim banyak pesan dengan pengenal unik yang sama. Entah sengaja atau sengaja jahat. Meminta server menghasilkan pengidentifikasi ini merupakan satu lapisan keamanan tambahan dan mengurangi kemungkinan sumber kesalahan.
Sekarang. Ketika server menerima pesan dari klien, server harus meneruskannya ke setiap klien lainnya. Namun, ini berarti kami harus melacak setiap klien yang terhubung.
Kembali ke main.swift
proyek server. Tepat di atas app.webSocket("chat")
letakkan deklarasi berikut:
var clientConnections = Set < WebSocket > ( )
Di sinilah kami akan menyimpan koneksi klien kami.
Tapi tunggu ... Anda seharusnya mendapatkan kesalahan kompilasi yang besar, buruk, dan buruk. Itu karena objek WebSocket
tidak sesuai dengan protokol Hashable
secara default. Namun jangan khawatir, hal ini dapat diterapkan dengan mudah (walaupun murah). Tambahkan kode berikut di bagian paling bawah 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 ) )
}
}
Badabing badaboom. Kode di atas adalah cara cepat namun sederhana untuk membuat class
sesuai dengan Hashable
(dan menurut definisi juga Equatable
), hanya dengan menggunakan alamat memorinya sebagai properti unik. Catatan : ini hanya berfungsi untuk kelas. Structs akan membutuhkan lebih banyak implementasi langsung.
Baiklah, jadi sekarang kita dapat melacak klien, ganti semua app.webSocket("chat")
(termasuk penutupan dan isinya) dengan kode berikut?:
app . webSocket ( " chat " ) { req , client in
clientConnections . insert ( client )
client . onClose . whenComplete { _ in
clientConnections . remove ( client )
}
}
Saat klien terhubung, simpan klien tersebut ke clientConnections
. Saat klien terputus, hapus dari Set
yang sama. Ezpz.
Langkah terakhir dalam bab ini adalah menambahkan jantung serverclient.onClose.whenComplete
- namun masih di dalam penutupan app.webSocket("chat")
- tambahkan cuplikan kode berikut:
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
}
}
Sekali lagi, dari atas:
.onText
ke klien yang terhubung.ReceivingChatMessage
dengan pesan yang diterima dari klien.ReceivingChatMessage
akan dibuat secara otomatis.ReceivingChatMessage
ke string JSON (dan juga Data
).Mengapa mengirimkannya kembali?
Kita dapat menggunakan ini sebagai konfirmasi bahwa pesan tersebut sebenarnya telah berhasil diterima dari klien. Aplikasi akan menerima kembali pesan tersebut sama seperti menerima pesan lainnya. Ini akan menghindarkan kita dari keharusan menulis kode tambahan di kemudian hari.
Selesai! Server siap menerima pesan dan meneruskannya ke klien lain yang terhubung. Jalankan server dan biarkan diam di latar belakang, saat kita melanjutkan dengan aplikasi!
Ingat struct SubmittedChatMessage
dan ReceivingChatMessage
yang kami buat untuk server? Kami juga membutuhkannya untuk aplikasi. Buat file Swift baru dan beri nama Models.swift
. Meskipun Anda dapat menyalin-menempelkan implementasinya, penerapannya memerlukan sedikit modifikasi:
import Foundation
struct SubmittedChatMessage : Encodable {
let message : String
}
struct ReceivingChatMessage : Decodable , Identifiable {
let date : Date
let id : UUID
let message : String
}
Perhatikan bagaimana protokol Encodable
dan Decodable
telah ditukar. Masuk akal: di aplikasi, kami hanya menyandikan SubmittedChatMessage
dan hanya mendekode ReceivingChatMessage
. Kebalikan dari server. Kami juga menghapus inisialisasi otomatis date
dan id
. Aplikasi tidak punya urusan untuk menghasilkan ini.
Oke, kembali ke ChatScreenModel
(baik di file terpisah atau di bagian bawah ChatScreen.swift
). Tambahkan bagian atas, tetapi di dalam ChatScreenModel
tambahkan properti instance berikut:
@ Published private ( set ) var messages : [ ReceivingChatMessage ] = [ ]
Di sinilah kami akan menyimpan pesan yang diterima. Berkat @Published
, ChatScreen
akan mengetahui secara pasti kapan array ini diperbarui dan akan bereaksi terhadap perubahan ini. private(set)
memastikan hanya ChatScreenModel
yang dapat memperbarui properti ini. (Bagaimanapun, itu adalah pemilik datanya. Tidak ada objek lain yang dapat memodifikasinya secara langsung!)
Masih di dalam ChatScreenModel
, tambahkan metode berikut:
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
}
}
}
Tampaknya cukup jelas. Namun demi konsistensi:
SubmittedChatMessage
yang, untuk saat ini, hanya menampung pesan. Buka ChatScreen.swift
dan tambahkan metode berikut ke ChatScreen
:
private func onCommit ( ) {
if !message . isEmpty {
model . send ( text : message )
message = " "
}
}
Metode ini akan dipanggil ketika pengguna menekan tombol kirim atau ketika menekan Return pada keyboard. Meskipun itu hanya akan mengirimkan pesan jika itu benar-benar berisi sesuatu .
Di .body
ChatScreen
, temukan TextField
dan Button
, lalu ganti keduanya (tetapi bukan pengubah atau kontennya) dengan inisialisasi berikut:
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
Ketika tombol Return ditekan saat TextField
fokus, onCommit
akan dipanggil. Hal yang sama berlaku ketika Button
ditekan oleh pengguna. TextField
juga memerlukan argumen onEditingChanged
- namun kami membuangnya dengan memberikan penutupan kosong.
Sekaranglah waktunya untuk mulai menguji apa yang kita miliki. Pastikan server masih berjalan di latar belakang. Tempatkan beberapa breakpoint di penutupan client.onText
(tempat server membaca pesan masuk) di main.swift
server. Jalankan aplikasi dan kirim pesan. Breakpoint di main.swift
harus dicapai saat menerima pesan dari aplikasi. Jika ya, ? subur ! ? Jika tidak, ya... telusuri kembali langkah Anda dan mulai debugging!
Mengirim pesan itu lucu dan sebagainya. Tapi bagaimana dengan menerimanya? (Yah, secara teknis kami menerimanya, hanya saja kami tidak pernah bereaksi.) Benar sekali!
Mari kunjungi ChatScreenModel
sekali lagi. Ingat metode onReceive(incoming:)
itu? Ganti dan berikan metode saudara seperti yang ditunjukkan di bawah ini:
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 )
}
}
}
Jadi...
URLSessionWebSocketTask
? Mereka hanya bekerja satu kali. Jadi, kami langsung mengikat ulang pengendali baru, sehingga kami siap membaca pesan masuk berikutnya.ReceivingChatMessage
.self.messages
. Namun , karena URLSessionWebSocketTask
bisa memanggil pengendali penerimaan pada thread yang berbeda, dan karena SwiftUI hanya bekerja pada thread utama, kita harus menggabungkan modifikasi kita dalam DispatchQueue.main.async {}
, untuk memastikan bahwa kita benar-benar melakukan modifikasi pada thread tersebut. benang utama.Menjelaskan bagaimana dan mengapa bekerja dengan thread berbeda di SwiftUI berada di luar cakupan tutorial ini.
Hampir sampai!
Periksa kembali di ChatScreen.swift
. Lihat ScrollView
kosong itu? Kami akhirnya dapat mengisinya dengan pesan:
ScrollView {
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
}
}
}
Ini sama sekali tidak akan terlihat spektakuler. Tapi ini akan berhasil untuk saat ini. Kami hanya mewakili setiap pesan dengan o' Text
biasa.
Silakan, jalankan aplikasinya. Saat Anda mengirim pesan, pesan itu akan langsung muncul di layar. Ini mengonfirmasi bahwa pesan berhasil dikirim ke server, dan server berhasil mengirimkannya kembali ke aplikasi! Sekarang, jika Anda bisa, buka beberapa aplikasi (tip: gunakan Simulator yang berbeda). Hampir tidak ada batasan jumlah klien! Adakan pesta obrolan besar yang menyenangkan sendirian.
Terus kirim pesan hingga tidak ada ruang tersisa di layar. Perhatikan sesuatu? yap. ScrollView
tidak secara otomatis menggulir ke bawah setelah pesan baru berada di luar batas layar. ?
Memasuki...
Ingat, server menghasilkan pengidentifikasi unik untuk setiap pesan. Kami akhirnya bisa memanfaatkannya dengan baik! Penantiannya tidak sia-sia untuk hasil yang luar biasa ini, saya jamin.
Di ChatScreen
, ubah ScrollView
menjadi keindahan ini:
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 )
}
}
}
Kemudian tambahkan metode berikut:
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
dalam ScrollViewReader
.ScrollViewReader
memberi kita proxy
yang akan kita perlukan segera.model.messages.count
. Ketika nilai ini berubah, kita memanggil metode yang baru saja kita tambahkan, meneruskannya ke proxy
yang disediakan oleh ScrollViewReader
..scrollTo(_:anchor:)
dari ScrollViewProxy
. Ini memberitahu ScrollView
untuk menggulir ke Tampilan dengan pengidentifikasi yang diberikan. Kami menggabungkannya dengan withAnimation {}
untuk menganimasikan pengguliran.Dan voila...
Pesan-pesan ini cukup subur... tetapi akan lebih indah lagi jika kita mengetahui siapa yang mengirim pesan dan secara visual membedakan antara pesan yang diterima dan dikirim.
Dengan setiap pesan kami juga akan melampirkan nama pengguna dan pengenal pengguna. Karena nama pengguna saja tidak cukup untuk mengidentifikasi pengguna, kita memerlukan sesuatu yang unik. Bagaimana jika nama pengguna dan orang lain adalah Patrick? Kami akan mengalami krisis identitas dan tidak dapat membedakan antara pesan yang dikirim oleh Patrick dan pesan yang diterima oleh Patrick .
Sesuai tradisi, kami memulai dengan server, yang merupakan pekerjaan paling sedikit.
Buka Models.swift
tempat kita mendefinisikan SubmittedChatMessage
dan ReceivingChatMessage
. Berikan properti user: String
dan userID: UUID
kepada kedua bocah nakal ini, seperti:
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
}
(Jangan lupa juga memperbarui file Models.swift di proyek aplikasi!)
Kembali ke main.swift
, di mana Anda akan disambut dengan kesalahan, ubah inisialisasi ReceivingChatMessage
menjadi berikut:
let outgoingMessage = ReceivingChatMessage (
message : incomingMessage . message ,
user : incomingMessage . user ,
userID : incomingMessage . userID
)
Dan itu saja ! Kami sudah selesai dengan servernya. Ini hanya aplikasi mulai saat ini. Peregangan rumah!
Dalam proyek Xcode aplikasi, buat file Swift baru bernama UserInfo.swift
. Tempatkan kode berikut di sana:
import Combine
import Foundation
class UserInfo : ObservableObject {
let userID = UUID ( )
@ Published var username = " "
}
Ini akan menjadi EnvironmentObject
tempat kita menyimpan nama pengguna. Seperti biasa, pengidentifikasi unik adalah UUID yang dibuat secara otomatis dan tidak dapat diubah. Nama penggunanya dari mana? Pengguna akan memasukkan ini saat membuka aplikasi, sebelum disajikan layar obrolan.
Waktu file baru: SettingsScreen.swift
. File ini akan menampung formulir pengaturan sederhana:
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
yang dibuat sebelumnya akan dapat diakses di sini sebagai EnvironmentObject
.TextField
akan langsung menulis isinya ke userInfo.username
.NavigationLink
yang akan menampilkan ChatScreen
saat ditekan. Tombol dinonaktifkan jika nama pengguna tidak valid. (Apakah Anda memperhatikan bagaimana kami menginisialisasi ChatScreen
di NavigationLink
? Seandainya kami membuat ChatScreen
terhubung ke server di init()
, hal itu akan dilakukan sekarang juga !)Jika mau, Anda dapat menambahkan sedikit panache ke layar.
Karena kita menggunakan fitur navigasi SwiftUI, kita perlu memulai dengan NavigationView
di suatu tempat. ContentView
adalah tempat yang tepat untuk ini. Ubah implementasi ContentView
sebagai berikut:
struct ContentView : View {
@ StateObject private var userInfo = UserInfo ( ) // 1
var body : some View {
NavigationView {
SettingsScreen ( )
}
. environmentObject ( userInfo ) // 2
. navigationViewStyle ( StackNavigationViewStyle ( ) ) // 3
}
}
UserInfo
dan...EnvironmentObject
, sehingga dapat diakses oleh semua tampilan berikutnya. Sekarang untuk mengirim data UserInfo
beserta pesan yang kita kirim ke server. Buka ChatScreenModel
(di mana pun Anda meletakkannya). Di bagian atas kelas tambahkan properti berikut:
final class ChatScreenModel : ObservableObject {
private var username : String ?
private var userID : UUID ?
// the rest ...
}
ChatModelScreen
harus menerima nilai-nilai ini saat menghubungkan. Bukan tugas ChatModelScreen
untuk mengetahui dari mana informasi ini berasal. Jika, di masa mendatang, kami memutuskan untuk mengubah tempat penyimpanan username
dan userID
, kami tidak akan menyentuh ChatModelScreen
.
Ubah metode connect()
untuk menerima properti baru ini sebagai argumen:
func connect ( username : String , userID : UUID ) {
self . username = username
self . userID = userID
// etc ...
}
Terakhir, di send(text:)
, kita perlu menerapkan nilai baru ini ke SubmittedChatMessage
yang kita kirim ke server:
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 ...
}
Aaa dan itu saja untuk ChatScreenModel
. Sudah selesai . ??
Untuk terakhir kalinya, buka ChatScreen.swift
. Di bagian atas ChatScreen
tambahkan:
@ EnvironmentObject private var userInfo : UserInfo
Jangan lupa untuk memberikan username
dan userID
ke ChatScreenModel
ketika tampilan muncul:
private func onAppear ( ) {
model . connect ( username : userInfo . username , userID : userInfo . userID )
}
Sekarang, sekali lagi, seperti yang dipraktikkan: Bersandarlah di kursi itu dan lihat ke langit-langit. Seperti apa bentuk pesan teksnya? Jika Anda tidak berminat untuk berpikir kreatif, Anda dapat menggunakan Tampilan berikut yang mewakili satu pesan yang diterima (dan dikirim):
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 ( )
}
}
}
}
Tampilannya tidak terlalu menarik. Berikut tampilannya di iPhone:
(Ingat bagaimana server juga mengirimkan tanggal pesan? Di sini digunakan untuk menampilkan waktu.)
Warna dan posisi didasarkan pada properti isUser
yang diturunkan oleh induknya. Dalam hal ini, induk tersebut tidak lain adalah ChatScreen
. Karena ChatScreen
memiliki akses ke pesan serta UserInfo
, di situlah logika ditempatkan untuk menentukan apakah pesan tersebut milik pengguna atau bukan.
ChatMessageRow
menggantikan Text
membosankan yang kami gunakan sebelumnya untuk mewakili pesan:
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.
}
}
Selamat datang di garis finis! Anda telah berhasil sampai ke sini! Untuk terakhir kalinya,
Saat ini Anda seharusnya sudah memiliki aplikasi obrolan yang primitif - namun berfungsi. Serta server yang menangani pesan masuk dan keluar. Semua ditulis dalam Swift!
Selamat! Dan terima kasih banyak telah membaca! ?
Anda dapat mengunduh kode terakhir dari Github.
Kiri: iPad mungil dengan tampilan gelap. Kanan: iPhone besar dengan tampilan ringan.
Mari kita simpulkan perjalanan kita:
Semua itu sementara benar -benar tinggal di dalam ekosistem yang cepat. Tidak ada bahasa pemrograman tambahan, tidak ada cocoapod atau apapun.
Tentu saja, apa yang kami buat di sini hanyalah sebagian kecil dari sebagian kecil dari aplikasi dan server obrolan siap produksi yang lengkap. Kami memotong banyak sudut untuk menghemat waktu dan kompleksitas. Tak perlu dikatakan itu harus memberikan pemahaman yang cukup mendasar tentang cara kerja aplikasi obrolan.
Pertimbangkan fitur -fitur berikut untuk, mungkin, terapkan diri Anda:
ForEach
, kami beralih melalui setiap pesan dalam memori. Perangkat lunak obrolan modern hanya melacak beberapa pesan untuk diterjemahkan, dan hanya memuat pesan yang lebih lama begitu pengguna menggulir.API UrlSessionWebSocketTask yang aneh
Jika Anda pernah bekerja dengan Websockets sebelumnya, Anda dapat membagikan pendapat bahwa API Apple untuk Websocket cukup ... non-tradisional. Anda tentu saja tidak sendirian dalam hal ini. Harus terus -menerus menebus penangan terima itu aneh . Jika Anda pikir Anda lebih nyaman menggunakan API Websocket yang lebih tradisional untuk iOS dan macOS maka saya pasti akan merekomendasikan Starscream. Ini diuji dengan baik, berkinerja dan bekerja pada versi iOS yang lebih lama.
Bug Bugs Bugs
Tutorial ini ditulis menggunakan Xcode 12 Beta 5 dan iOS 14 Beta 5. Bug muncul dan menghilang di antara setiap versi beta baru. Sayangnya tidak mungkin untuk memprediksi apa yang akan dan apa yang tidak akan berhasil di masa depan (beta) rilis.
Server tidak hanya berjalan di mesin lokal Anda, tetapi hanya dapat diakses dari mesin lokal Anda. Ini bukan masalah saat menjalankan aplikasi dalam simulator (atau sebagai aplikasi macOS pada mesin yang sama). Tetapi menjalankan aplikasi pada perangkat fisik, atau pada Mac yang berbeda, server harus dapat diakses di jaringan lokal Anda.
Untuk melakukan ini, di main.swift
kode server, tambahkan baris berikut langsung setelah menginisialisasi instance Application
:
app . http . server . configuration . hostname = " 0.0.0.0 "
Sekarang di ChatScreenModel
, di metode connect(username:userID:)
, Anda perlu mengubah URL agar sesuai dengan IP lokal mesin Anda:
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
IP lokal mesin Anda dapat ditemukan dengan berbagai cara. Secara pribadi saya selalu hanya membuka preferensi sistem> jaringan , di mana IP ditampilkan secara langsung dan dapat dipilih dan disalin.
Perlu dicatat bahwa tingkat keberhasilan ini bervariasi antara jaringan. Ada banyak faktor (seperti keamanan) yang dapat mencegahnya bekerja.
Terima kasih banyak telah membaca! Jika Anda memiliki pendapat tentang bagian ini, pemikiran untuk perbaikan, atau menemukan beberapa kesalahan, tolong, tolong beri tahu saya! Saya akan melakukan yang terbaik untuk terus meningkatkan tutorial ini. ?