Создание очень примитивного чат-приложения в SwiftUI с использованием Swift и WebSockets для создания чат-сервера. Это Свифт сверху донизу, пчелка!
В этом уроке мы создадим довольно примитивное, но функциональное приложение для общения. Приложение будет работать на iOS или macOS — или на обоих! Прелесть SwiftUI в том, как мало усилий требуется для создания мультиплатформенного приложения.
Конечно, приложение чата будет бесполезно без сервера, с которым можно общаться. Следовательно, мы также будем создавать очень примитивный чат-сервер, используя WebSockets. Все будет построено на Swift и запущено локально на вашем компьютере.
В этом руководстве предполагается, что у вас уже есть некоторый опыт разработки приложений для iOS/macOS с использованием SwiftUI. Хотя концепции будут объясняться по ходу дела, не все будет рассмотрено подробно. Излишне говорить, что если вы наберете текст и будете следовать инструкциям, к концу этого руководства у вас будет работающее приложение для чата (для iOS и/или macOS), которое взаимодействует с сервером, который вы также создали! Вы также получите базовое представление о таких концепциях, как серверный Swift и WebSockets.
Если вас ничего из этого не интересует, вы всегда можете прокрутить до конца и загрузить окончательный исходный код!
Короче говоря, мы начнем с создания очень простого и безликого сервера. Мы создадим сервер как пакет Swift, а затем добавим веб-фреймворк Vapor в качестве зависимости. Это поможет нам настроить сервер WebSocket всего с помощью нескольких строк кода.
После этого мы начнем создавать интерфейсное приложение для чата. Быстро начинаем с основ, а затем добавляем функции (и предметы первой необходимости) одну за другой.
Большая часть нашего времени будет потрачена на работу над приложением, но мы будем переходить от кода сервера к коду приложения по мере добавления новых функций.
Необязательный
Начнем!
Откройте Xcode 12 и запустите новый проект ( Файл > Новый проект ). В разделе «Мультиплатформа» выберите «Swift Package» .
Назовите пакет как-нибудь логичным и понятным, например, « ChatServer ». Затем сохраните его где угодно.
Быстрый пакет?
При создании фреймворка или мультиплатформенного программного обеспечения (например, Linux) в Swift предпочтительным способом являются пакеты Swift. Это официальное решение для создания модульного кода, который могут легко использовать другие проекты Swift. Однако пакет Swift не обязательно должен быть модульным проектом: он также может быть автономным исполняемым файлом, который просто использует другие пакеты Swift в качестве зависимостей (что мы и делаем).
Возможно, вам пришло в голову, что для пакета Swift не существует проекта Xcode (
.xcodeproj
). Чтобы открыть пакет Swift в Xcode, как и любой другой проект, просто откройте файлPackage.swift
. Xcode должен распознать, что вы открываете пакет Swift, и открыть всю структуру проекта. Он автоматически получит все зависимости в начале.Вы можете прочитать больше о пакетах Swift и диспетчере пакетов Swift на официальном сайте Swift.
Чтобы справиться со всей тяжелой работой по настройке сервера, мы будем использовать веб-фреймворк Vapor. Vapor поставляется со всеми необходимыми функциями для создания сервера WebSocket.
Вебсокеты?
Чтобы предоставить Интернету возможность взаимодействовать с сервером в реальном времени, были созданы WebSockets. Это хорошо описанная спецификация для безопасной связи в реальном времени (с низкой пропускной способностью) между клиентом и сервером. Например: многопользовательские игры и приложения для чата. Те захватывающие многопользовательские браузерные игры, в которые вы играли в драгоценное время компании? Ага, вебсокеты!
Однако, если вы хотите реализовать что-то вроде потоковой передачи видео в реальном времени, вам лучше поискать другое решение. ?
Хотя в этом руководстве мы создаем приложение для чата для iOS/macOS, сервер, который мы создаем, может так же легко взаимодействовать с другими платформами с помощью WebSockets. Действительно: если вы хотите, вы также можете создать версию этого чат-приложения для Android и веб-версию, общающуюся с одним и тем же сервером и обеспечивающую связь между всеми платформами!
Пар?
Интернет представляет собой сложную систему трубок. Даже ответ на простой HTTP-запрос требует серьезного объема кода. К счастью, эксперты в этой области разработали веб-фреймворки с открытым исходным кодом, которые вот уже несколько десятилетий выполняют за нас всю тяжелую работу на различных языках программирования. Vapor — один из них, написанный на Swift. Он уже имеет некоторые возможности WebSocket, и это именно то, что нам нужно.
Однако Vapor — не единственный веб-фреймворк на базе Swift. 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 должен начать автоматически получать зависимости Vapor версии 4.0.0
или новее. Как и все его зависимости.
Нам просто нужно внести еще одну настройку в файл, пока Xcode делает свое дело: добавляет зависимость к нашей цели. В targets:
вы найдете .target(name: "ChatServer", dependencies: [])
. В этот пустой массив добавьте следующее:
. product ( name : " Vapor " , package : " vapor " )
Вот и все . Наш Package.swift
готов. Мы описали наш пакет Swift, сказав:
Окончательный Package.swift
должен выглядеть так (-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 " )
] ) ,
]
)
Теперь, наконец, пришло время...
В 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
? Бам! Это все, что нужно для запуска сервера (WebSocket) с использованием Vapor. Посмотрите, как легко это было.
defer
и вызовите .shutdown()
который выполнит любую очистку при выходе из программы./chat
. Сейчас
После успешного запуска программы вы можете не увидеть ничего похожего на приложение. Это связано с тем, что серверное программное обеспечение обычно не имеет графического пользовательского интерфейса. Но будьте уверены, программа жива и здорова, работая в фоновом режиме. Однако консоль Xcode должна отобразить следующее сообщение:
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
Это означает, что сервер может успешно прослушивать входящие запросы. Это здорово, потому что теперь у нас есть сервер WebSocket, к которому мы можем подключиться!
Я тебе не верю?
Если по какой-то причине вы думаете, что я все это время изрыгал только гнусную ложь, вы можете протестировать сервер самостоятельно!
Откройте свой любимый браузер и убедитесь, что вы находитесь на пустой вкладке. (Если это Safari, вам сначала необходимо включить режим разработчика.) Откройте инспектор (
Cmd
+Option
+I
) и перейдите в консоль . Введитеnew WebSocket ( 'ws://localhost:8080/chat' )и нажмите «Возврат». Теперь взгляните на консоль Xcode. Если все прошло хорошо, теперь должно отображаться
Connected: WebSocketKit.WebSocket
.????
Сервер доступен только с вашего локального компьютера. Это означает, что вы не можете подключить свой физический iPhone/iPad к серверу. Вместо этого мы будем использовать симулятор на следующих этапах для тестирования нашего чат-приложения.
Чтобы протестировать приложение чата на физическом устройстве, необходимо предпринять некоторые (небольшие) дополнительные шаги. См. Приложение А.
Хотя мы еще не закончили с серверной частью, пришло время перейти к интерфейсу. Само приложение чата!
В Xcode создайте новый проект. На этот раз в разделе «Мультиплатформа» выберите «Приложение» . Опять же, выберите красивое имя для своего приложения и продолжайте. (Я выбрал SwiftChat . Согласен, он идеален ?)
Приложение не зависит от каких-либо внешних сторонних фреймворков или библиотек. Действительно, все, что нам нужно, доступно через Foundation
, Combine
и SwiftUI
(в Xcode 12+).
Давайте немедленно приступим к работе над экраном чата. Создайте новый файл Swift и назовите его ChatScreen.swift
. Не имеет значения, выберете ли вы файл Swift или шаблон представления SwiftUI . В любом случае мы удаляем в нем все.
Вот стартовый набор 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.
Что мы имеем здесь:
Если вы хотите сделать другой выбор дизайна, продолжайте. ?
Теперь давайте начнем работать над логикой, не связанной с пользовательским интерфейсом: подключением к тому самому серверу, который мы только что создали.
SwiftUI вместе с платформой Joint предоставляет разработчикам инструменты для простой реализации разделения задач в их коде. Используя протокол ObservableObject
и оболочки свойств @StateObject
(или @ObservedObject
), мы можем реализовать логику, не относящуюся к пользовательскому интерфейсу (называемую бизнес-логикой ), в отдельном месте. Так и должно быть! В конце концов, единственное, о чем должен заботиться пользовательский интерфейс, — это отображение данных пользователю и реакция на ввод пользователя. Его не должно волновать, откуда берутся данные или как ими манипулируют.
Учитывая опыт работы с React, я невероятно завидую этой роскоши.
Существуют тысячи и тысячи статей и дискуссий об архитектуре программного обеспечения. Вы, наверное, слышали или читали о таких концепциях, как MVC, MVVM, VAPOR, чистая архитектура и других. У всех есть свои аргументы и свои применения.
Обсуждение этих вопросов выходит за рамки данного руководства. Но общепринято, что бизнес-логика и логика пользовательского интерфейса не должны переплетаться.
Эта концепция справедлива и для нашего 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. Они являются резидентами семейства URLSession
в рамках Foundation .127.0.0.1
или localhost
). Порт приложений Vapor по умолчанию — 8080
. И мы помещаем прослушиватель соединений WebSocket в путь /chat
.URLSessionWebSocketTask
и сохраняем его в свойстве экземпляра.onReceive(incoming:)
. Подробнее об этом позже.ChatScreenModel
из памяти. Это отличное начало. Теперь у нас есть место, куда мы можем поместить всю нашу логику WebSocket, не загромождая код пользовательского интерфейса. Пришло время заставить ChatScreen
взаимодействовать с ChatScreenModel
.
Добавьте ChatScreenModel
в качестве объекта состояния в ChatScreen
:
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
Когда нам следует подключиться к серверу? Ну, когда экран действительно виден, конечно. У вас может возникнуть соблазн вызвать .connect()
в init()
ChatScreen
. Это опасная вещь. Фактически, в SwiftUI следует стараться избегать использования init()
, поскольку представление может быть инициализировано, даже если оно никогда не появится. (Например, в LazyVStack
или NavigationLink(destination:)
.) Было бы обидно тратить драгоценные циклы процессора. Поэтому давайте отложим все на onAppear
.
Добавьте метод onAppear
в ChatScreen
. Затем добавьте и передайте этот метод модификатору .onAppear(perform:)
VStack
:
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
, вы должны увидеть сообщение Connected: WebSocketKit.WebSocket
в консоли Xcode сервера. Если нет, повторите свои действия и начните отладку!
И еще кое-что™️. Мы также должны проверить, закрывается ли соединение WebSocket, когда пользователь закрывает приложение (или покидает ChatScreen
). Вернитесь к файлу main.swift
серверного проекта. На данный момент наш прослушиватель WebSocket выглядит так:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
Добавьте обработчик в .onClose
client
, выполняющий только простую функцию print()
:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
Перезапустите сервер и запустите приложение чата. После подключения приложения закройте его (фактически выйдите из него, а не просто переводите его в фоновый режим). Консоль Xcode сервера теперь должна вывести Disconnected: WebSocketKit.WebSocket
. Это подтверждает, что соединения WebSocket действительно закрываются, когда они нам больше не нужны. Таким образом, на сервере не должно быть неработающих соединений, хранящихся в памяти.
Вы готовы отправить что-нибудь на сервер? Мальчик, я уверен. Но на минутку, давайте притормозим и подумаем секунду. Откиньтесь на спинку стула и смотрите бесцельно, но как-то целенаправленно в потолок...
Что именно мы будем отправлять на сервер? И, что не менее важно, что мы получим в ответ от сервера?
Ваша первая мысль может быть: «Ну, просто напиши, да?», и вы будете наполовину правы. А как насчет времени сообщения? А как насчет имени отправителя? А как насчет идентификатора, который сделает сообщение уникальным среди любого другого сообщения? У нас пока нет ничего, что могло бы позволить пользователю создать имя пользователя или что-то еще. Итак, давайте отложим это в сторону и сосредоточимся на отправке и получении сообщений.
Нам придется внести некоторые изменения как на стороне приложения, так и на стороне сервера. Начнем с сервера.
Создайте новый файл Swift в Sources/ChatServer
под названием Models.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
) местоположение клиента будет учитываться автоматически. Добавление или вычитание часов в зависимости от часового пояса клиента.
УУИД?
Универсально уникальные идентификаторы во всем мире считаются приемлемыми значениями идентификаторов.
Мы также не хотим, чтобы клиент отправлял несколько сообщений с одним и тем же уникальным идентификатором. Случайно или намеренно злонамеренно. Создание сервером этого идентификатора — это еще один дополнительный уровень безопасности и меньше возможных источников ошибок.
Итак, теперь. Когда сервер получает сообщение от клиента, он должен передать его всем остальным клиентам. Однако это означает, что мы должны отслеживать каждого подключенного клиента.
Вернемся к 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
. Эзпз.
Последний шаг в этой главе — добавление сердца сервера.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 на клавиатуре. Хотя сообщение будет отправлено только в том случае, если оно действительно что-то содержит .
В .body
ChatScreen
найдите TextField
и Button
, затем замените их (но не их модификаторы или содержимое) следующими инициализациями:
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
Когда клавиша Return нажата, когда TextField
находится в фокусе, будет вызван onCommit
. То же самое происходит, когда пользователь нажимает Button
. TextField
также требует аргумент onEditingChanged
, но мы отбрасываем его, давая ему пустое замыкание.
Сейчас самое время начать тестировать то, что у нас есть. Убедитесь, что сервер все еще работает в фоновом режиме. Поместите несколько точек останова в замыкание client.onText
(где сервер читает входящие сообщения) в main.swift
сервера. Запустите приложение и отправьте сообщение. Точки останова в 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
. Когда это значение изменяется, мы вызываем только что добавленный метод, передавая ему proxy
предоставленный ScrollViewReader
..scrollTo(_:anchor:)
ScrollViewProxy
. Это сообщает ScrollView
о необходимости прокрутки до представления с заданным идентификатором. Мы обертываем это withAnimation {}
чтобы анимировать прокрутку.И вуаля...
Эти сообщения довольно пышные... но было бы еще пышнее, если бы мы знали, кто отправил сообщения, и визуально различали полученные и отправленные сообщения.
К каждому сообщению мы также будем прикреплять имя пользователя и идентификатор пользователя. Поскольку имени пользователя недостаточно для идентификации пользователя, нам нужно что-то уникальное. Что, если пользователя и всех остальных зовут Патрик? У нас возник бы кризис идентичности, и мы не смогли бы отличить сообщения, отправленные Патриком, от сообщений, полученных Патриком .
По традиции начинаем с сервера, здесь работы меньше всего.
Откройте Models.swift
, где мы определили SubmittedChatMessage
и ReceivingChatMessage
. Дайте обоим этим плохим парням свойство 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 приложения создайте новый файл Swift с именем UserInfo.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
.NavigationLink
, которая отображает ChatScreen
при нажатии. Кнопка отключена, пока имя пользователя недействительно. (Вы заметили, как мы инициализируем ChatScreen
в NavigationLink
? Если бы мы заставили 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 раньше, вы можете поделиться мнением, что API Apple для WebSocket является ... нетрадиционным. Вы, конечно, не одиноки в этом. Необходимость постоянно переживать обработчик приема - это просто странная . Если вы думаете, что вам удобнее использовать более традиционный API WebSocket для iOS и MacOS, я, безусловно, рекомендую StarsCream. Он хорошо протестирован, исполняется и работает на старых версиях iOS.
Ошибки ошибки ошибки
Этот урок был написан с использованием бета -версии 5 и iOS 14 и iOS 14. Ошибки появляются и исчезают между каждой новой бета -версией. К сожалению, невозможно предсказать, что будет, а что не сработает в будущих (бета) выпусках.
Сервер не только работает на вашей локальной машине, но и доступен только от вашей локальной машины. Это не проблема при запуске приложения в симуляторе (или в качестве приложения MacOS на той же машине). Но запустив приложение на физическом устройстве или на другом Mac, сервер должен быть доступен в вашей локальной сети.
Чтобы сделать это, в main.swift
кода сервера добавьте следующую строку непосредственно после инициализации экземпляра Application
:
app . http . server . configuration . hostname = " 0.0.0.0 "
Теперь в ChatScreenModel
, в connect(username:userID:)
Метод, вам нужно изменить URL, чтобы соответствовать локальному IP вашей машины:
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
Локальный IP вашей машины можно найти по -разному. Лично я всегда просто открываю системные предпочтения> сеть , где IP -адрес непосредственно отображается и может быть выбран и копирован.
Следует отметить, что уровень успеха этого варьируется между сетями. Есть много факторов (таких как безопасность), которые могут помешать этому работать.
Большое спасибо за чтение! Если у вас есть какие -либо мнения по этому поводу, мысли об улучшениях или обнаружили некоторые ошибки, пожалуйста, пожалуйста , дайте мне знать! Я сделаю все возможное, чтобы постоянно улучшать этот урок. ?