Swift와 WebSocket을 사용하여 채팅 서버를 생성하는 동시에 SwiftUI에서 매우 원시적인 채팅 앱을 생성합니다. 위에서 아래까지 Swift입니다, 베이비!
이 튜토리얼에서는 다소 원시적이지만 기능적인 채팅 앱을 만들어 보겠습니다. 앱은 iOS나 macOS 또는 둘 다에서 실행됩니다! SwiftUI의 장점은 멀티플랫폼 앱을 만드는 데 드는 노력이 매우 적다는 것입니다.
물론 채팅 앱은 대화할 서버가 없으면 거의 쓸모가 없습니다. 따라서 우리는 WebSocket을 활용하여 매우 원시적인 채팅 서버도 만들 것입니다. 모든 것이 Swift로 구축되어 귀하의 컴퓨터에서 로컬로 실행됩니다.
이 튜토리얼에서는 SwiftUI를 사용하여 iOS/macOS 앱을 개발한 경험이 이미 있다고 가정합니다. 개념은 진행하면서 설명되지만 모든 내용을 깊이 다루지는 않습니다. 말할 필요도 없이, 입력하고 단계를 따르면 이 튜토리얼이 끝날 때쯤에는 여러분이 만든 서버와 통신하는 작동하는 채팅 앱(iOS 및/또는 macOS용)을 갖게 될 것입니다! 또한 서버 측 Swift 및 WebSocket과 같은 개념에 대한 기본적인 이해도 갖추게 됩니다.
그 중 어느 것도 관심이 없다면 언제든지 끝까지 스크롤하여 최종 소스 코드를 다운로드할 수 있습니다!
간단히 말해서, 우리는 매우 간단하고 단순하며 기능이 없는 서버를 만드는 것부터 시작하겠습니다. 서버를 Swift 패키지로 구축한 다음 Vapor 웹 프레임워크를 종속성으로 추가하겠습니다. 이는 단 몇 줄의 코드만으로 WebSocket 서버를 설정하는 데 도움이 됩니다.
그런 다음 프런트엔드 채팅 앱 구축을 시작합니다. 기본 사항부터 빠르게 시작한 다음 기능(및 필수 사항)을 하나씩 추가합니다.
대부분의 시간은 앱 작업에 소요되지만, 새로운 기능을 추가하면서 서버 코드와 앱 코드 사이를 오가게 될 것입니다.
선택 과목
시작하자!
Xcode 12를 열고 새 프로젝트를 시작합니다( File > New Project ). 다중 플랫폼 에서 Swift 패키지를 선택합니다.
" ChatServer "와 같이 논리적인 것, 즉 설명이 필요 없는 패키지를 호출합니다. 그런 다음 원하는 곳에 저장하세요.
스위프트 패키지?
Swift에서 프레임워크 또는 다중 플랫폼 소프트웨어(예: Linux)를 만들 때 Swift 패키지가 선호되는 방법입니다. 이는 다른 Swift 프로젝트에서 쉽게 사용할 수 있는 모듈식 코드를 생성하기 위한 공식 솔루션입니다. 하지만 Swift 패키지가 반드시 모듈식 프로젝트일 필요는 없습니다. 또한 단순히 다른 Swift 패키지를 종속성으로 사용하는 독립형 실행 파일일 수도 있습니다(이것이 우리가 수행하는 작업입니다).
Swift 패키지에 Xcode 프로젝트(
.xcodeproj
)가 없다는 생각이 들었을 수도 있습니다. 다른 프로젝트처럼 Xcode에서 Swift 패키지를 열려면Package.swift
파일을 열면 됩니다. Xcode는 Swift 패키지를 여는 것을 인식하고 전체 프로젝트 구조를 열어야 합니다. 시작 시 모든 종속성을 자동으로 가져옵니다.Swift 패키지 및 Swift 패키지 관리자에 대한 자세한 내용은 공식 Swift 웹사이트에서 확인할 수 있습니다.
서버 설정의 모든 무거운 작업을 처리하기 위해 Vapor 웹 프레임워크를 사용할 것입니다. Vapor에는 WebSocket 서버를 생성하는 데 필요한 모든 기능이 포함되어 있습니다.
웹소켓?
웹에 실시간으로 서버와 통신할 수 있는 기능을 제공하기 위해 WebSocket이 만들어졌습니다. 클라이언트와 서버 간의 안전한 실시간(낮은 대역폭) 통신을 위해 잘 설명된 사양입니다. 예: 멀티플레이어 게임 및 채팅 앱. 소중한 회사 시간에 중독성 강한 브라우저 내 멀티플레이어 게임을 즐겨 보신 적 있으신가요? 네, WebSocket입니다!
그러나 실시간 비디오 스트리밍과 같은 작업을 수행하려면 다른 솔루션을 찾는 것이 가장 좋습니다. ?
이 튜토리얼에서는 iOS/macOS 채팅 앱을 만들고 있지만, 우리가 만들고 있는 서버는 WebSocket을 사용하여 다른 플랫폼과 쉽게 통신할 수 있습니다. 실제로: 원한다면 이 채팅 앱의 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 " ] )
결국 우리 서버는 라이브러리가 아닙니다. 그러나 오히려 독립 실행형 실행 파일입니다. 또한 서버가 실행될 것으로 예상하는 플랫폼(및 최소 버전)을 정의해야 합니다. name: "ChatServer"
아래에 platforms: [.macOS(v10_15)]
추가하면 됩니다.
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
이 모든 것이 Xcode에서 Swift 패키지를 '실행 가능'하게 만듭니다.
좋습니다. Vapor을 종속성으로 추가해 보겠습니다. dependencies: []
(일부 주석 처리된 항목이 있어야 함)에 다음을 추가합니다.
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
Package.swift
파일을 저장할 때 Xcode는 버전 4.0.0
이상에서 Vapor 종속성을 자동으로 가져오기 시작해야 합니다. 모든 종속성 도 마찬가지입니다.
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
? 빵! 이것이 Vapor을 사용하여 (WebSocket) 서버를 시작하는 데 필요한 전부입니다. 그게 얼마나 수월한지 보세요.
defer
를 등록하고 프로그램을 종료할 때 정리를 수행하는 .shutdown()
을 호출합니다./chat
에서 들어오는 WebSocket 연결 듣기를 시작하세요. 지금
프로그램이 성공적으로 실행되면 앱과 유사한 내용이 표시되지 않을 수 있습니다. 그 이유는 서버 소프트웨어에는 그래픽 사용자 인터페이스가 없는 경향이 있기 때문입니다. 그러나 안심하십시오. 프로그램은 백그라운드에서 잘 작동하며 바퀴를 돌립니다. 그러나 Xcode 콘솔에는 다음 메시지가 표시되어야 합니다.
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
이는 서버가 들어오는 요청을 성공적으로 수신할 수 있음을 의미합니다. 이제 연결을 시작할 수 있는 WebSocket 서버가 생겼으니 정말 좋습니다!
나는 당신을 믿지 않는다?
어떤 이유에서든 제가 지금껏 극악무도한 거짓말만 했다고 생각하신다면 서버를 직접 테스트해 보세요!
즐겨 사용하는 브라우저를 열고 빈 탭이 있는지 확인하세요. (Safari의 경우 먼저 개발자 모드를 활성화해야 합니다.) Inspector (
Cmd
+Option
+I
)를 열고 콘솔 로 이동합니다. 입력하세요new WebSocket ( 'ws://localhost:8080/chat' )그리고 Return을 누르세요. 이제 Xcode 콘솔을 살펴보세요. 모든 것이 순조롭게 진행되면 이제
Connected: WebSocketKit.WebSocket
표시됩니다.
????
서버는 로컬 컴퓨터에서만 액세스할 수 있습니다. 즉, 실제 iPhone/iPad를 서버에 연결할 수 없습니다. 대신 다음 단계에서 시뮬레이터를 사용하여 채팅 앱을 테스트하겠습니다.
실제 장치에서 채팅 앱을 테스트하려면 몇 가지 (작은) 추가 단계를 수행해야 합니다. 부록 A를 참조하세요.
아직 백엔드 작업이 끝나지 않았지만 이제 프런트엔드로 이동할 차례입니다. 채팅 앱 자체!
Xcode에서 새 프로젝트를 만듭니다. 이번에는 Multiplatform 에서 App을 선택합니다. 다시 한번 앱에 아름다운 이름을 선택하고 계속하세요. ( SwiftChat을 선택했습니다. 동의합니다. 완벽 합니까?)
이 앱은 외부 타사 프레임워크나 라이브러리에 의존하지 않습니다. 실제로 필요한 모든 것은 Foundation
, Combine
및 SwiftUI
(Xcode 12+)를 통해 사용할 수 있습니다.
즉시 채팅 화면 작업을 시작하겠습니다. 새로운 Swift 파일을 생성하고 이름을 ChatScreen.swift
로 지정하세요. Swift File 또는 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.
여기에 있는 내용은 다음과 같습니다.
다른 디자인을 선택하고 싶다면 바로 진행하세요. ?
이제 UI와 관련되지 않은 로직 작업을 시작해 보겠습니다. 바로 우리가 만든 서버에 연결하는 것입니다.
SwiftUI 는 Combine 프레임워크와 함께 개발자에게 코드에서 우려 사항 분리를 쉽게 구현할 수 있는 도구를 제공합니다. ObservableObject
프로토콜과 @StateObject
(또는 @ObservedObject
) 속성 래퍼를 사용하여 UI가 아닌 로직( 비즈니스 로직 이라고 함)을 별도의 위치에 구현할 수 있습니다. 상황이 그래야만합니다! 결국 UI가 관심을 가져야 할 유일한 것은 사용자에게 데이터를 표시하고 사용자 입력에 반응하는 것입니다. 데이터가 어디서 왔는지, 어떻게 조작되는지는 중요하지 않습니다.
React 배경에서 나온 이러한 사치는 제가 엄청나게 부러워하는 것입니다.
소프트웨어 아키텍처에 관한 수많은 기사와 토론이 있습니다. 아마도 MVC, MVVM, VAPOR, Clean Architecture 등과 같은 개념에 대해 듣거나 읽어본 적이 있을 것입니다. 그들은 모두 자신의 주장과 적용을 가지고 있습니다.
이에 대해 논의하는 것은 이 튜토리얼의 범위를 벗어납니다. 그러나 비즈니스 로직과 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
입니다. 그리고 /chat
경로에 WebSocket 연결에 대한 리스너를 배치합니다.URLSessionWebSocketTask
를 생성하고 이를 인스턴스의 속성에 저장합니다.onReceive(incoming:)
메소드가 호출됩니다. 이에 대해서는 나중에 자세히 설명합니다.ChatScreenModel
이 메모리에서 제거될 때 정상적으로 연결이 끊어졌는지 확인하세요. 이것은 좋은 시작입니다. 이제 UI 코드를 어지럽히지 않고 모든 WebSocket 로직을 배치할 수 있는 공간이 생겼습니다. 이제 ChatScreen
ChatScreenModel
과 통신하도록 할 차례입니다.
ChatScreenModel
ChatScreen
의 상태 개체로 추가합니다.
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
언제 서버에 연결해야 합니까? 물론, 화면이 실제로 보일 때 말이죠. ChatScreen
의 init()
에서 .connect()
호출하고 싶을 수도 있습니다. 이것은 위험한 일입니다. 실제로 SwiftUI에서는 init()
에 아무것도 넣지 않도록 해야 합니다. View가 전혀 나타나지 않을 때에도 초기화될 수 있기 때문입니다. (예를 들어 LazyVStack
또는 NavigationLink(destination:)
에서.) 귀중한 CPU 사이클을 낭비하는 것은 부끄러운 일입니다. 그러므로 모든 것을 onAppear
로 연기합시다.
ChatScreen
에 onAppear
메소드를 추가합니다. 그런 다음 해당 메서드를 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 연결이 더 이상 신경 쓰지 않을 때 실제로 닫혀 있음을 확인합니다. 따라서 서버에는 메모리에 남아 있는 끊어진 연결이 없어야 합니다.
실제로 서버에 무언가를 보낼 준비가 되셨나요? 정말 그렇습니다. 하지만 잠시만 브레이크를 밟고 잠시 생각해보자. 의자에 기대어 멍하니, 그러나 왠지 의도적으로 천장을 바라보는...
서버에 정확히 무엇을 보내게 될까요? 그리고 마찬가지로 중요한 것은 서버로부터 무엇을 다시 받게 될까요?
당신의 첫 번째 생각은 "글쎄, 문자만 하면 되지, 그렇지?"일 수도 있지만 절반은 맞을 것입니다. 하지만 메시지가 전송된 시간은 어떻습니까? 보낸 사람 이름은 어떻게 되나요? 메시지를 다른 메시지와 고유하게 만드는 식별자는 어떻습니까? 아직은 사용자가 사용자 이름을 생성할 수 있는 항목이 없습니다. 그럼 그건 옆으로 치워두고 메시지를 보내고 받는 데만 집중해 보겠습니다.
우리는 앱 측과 서버 측 모두에서 몇 가지 조정을 해야 할 것입니다. 서버부터 시작해 보겠습니다.
서버 프로젝트의 Models.swift
라는 Sources/ChatServer
에 새 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
를 초기화하거나 1을 문자열로 형식화할 때(예:DateFormatter
통해) 클라이언트의 위치가 자동으로 고려됩니다. 클라이언트의 시간대에 따라 시간을 더하거나 뺍니다.
UUID?
보편적 으로 고유 한 식별자는 전역 적 으로 허용되는 식별자 값으로 간주됩니다.
또한 클라이언트가 동일한 고유 식별자를 사용하여 여러 메시지를 보내는 것을 원하지 않습니다. 실수로든 고의로든 악의적으로든 말이죠. 서버가 이 식별자를 생성하도록 하면 보안이 강화되고 오류 발생 가능성이 줄어듭니다.
이제 그럼. 서버가 클라이언트로부터 메시지를 받으면 이를 다른 모든 클라이언트에 전달해야 합니다. 그러나 이는 연결된 모든 클라이언트를 추적해야 함을 의미합니다.
서버 프로젝트의 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 )
}
}
}
어떤 의미에서도 화려해 보이지는 않을 것입니다. 하지만 지금은 이것으로 충분합니다. 우리는 단순히 o' 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이라면 어떻게 될까요? 우리는 정체성 위기에 직면하게 되고 Patrick이 보낸 메시지와 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
뷰가 나타날 때 ChatScreenModel
에 username
과 userID
제공하는 것을 잊지 마세요.
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.
우리의 여정을 요약해 보겠습니다.
신속한 생태계 내에 완전히 머무르는 동안 모든 것. 추가 프로그래밍 언어, 코코 포드 등이 없습니다.
물론, 우리가 여기서 만든 것은 완전한 프로덕션 준비 채팅 앱 및 서버의 일부에 불과합니다. 우리는 시간과 복잡성을 절약하기 위해 많은 모서리를 잘라 냈습니다. 말할 것도없이 채팅 앱의 작동 방식에 대한 기본적인 이해를 제공해야합니다.
아마도 다음과 같은 기능을 고려하십시오.
ForEach
View를 사용하여 메모리의 모든 메시지를 반복합니다. 최신 채팅 소프트웨어는 렌더링 할 소수의 메시지 만 추적하고 사용자가 스크롤하면 이전 메시지에만로드합니다.그 이상한 urlsessionwebsockettask api
WebSockets와 함께 일한 적이 있다면 WebSocket의 Appi의 API가 전통적이지 않은 의견을 공유 할 수 있습니다. 당신은 확실히 이것에 대해 혼자가 아닙니다. 수신 핸들러를 지속적으로 반복 해야하는 것은 이상합니다 . iOS 및 MACOS 용 더 전통적인 WebSocket API를 사용하는 것이 더 편한다고 생각되면 Starscream을 추천 할 것입니다. 잘 테스트되고 성능이 있으며 이전 버전의 iOS에서 작동합니다.
버그 버그 버그
이 튜토리얼은 Xcode 12 Beta 5 및 iOS 14 베타 5를 사용하여 작성되었습니다. 각 새로운 베타 버전 사이에 버그가 나타나고 사라집니다. 불행히도 미래에 무엇을하고 무엇이 작동하지 않을지 예측하는 것은 불가능합니다 (베타) 릴리스.
서버는 로컬 컴퓨터에서 실행될뿐만 아니라 로컬 컴퓨터에서만 액세스 할 수 있습니다 . 시뮬레이터에서 앱을 실행할 때 (또는 동일한 컴퓨터의 MacOS 앱) 문제가되지 않습니다. 그러나 물리적 장치 또는 다른 Mac에서 앱을 실행하려면 서버를 로컬 네트워크에서 액세스 할 수 있어야합니다.
이렇게하려면 main.swift
서버 코드의 스위프트에서 Application
인스턴스를 초기화 한 후 다음 줄을 추가하십시오.
app . http . server . configuration . hostname = " 0.0.0.0 "
이제 connect(username:userID:)
메소드의 ChatScreenModel
에서 컴퓨터의 로컬 IP와 일치하도록 URL을 변경해야합니다.
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
기계의 로컬 IP는 다양한 방식으로 찾을 수 있습니다. 개인적으로 나는 항상 IP가 직접 표시되어 선택 및 복사 할 수있는 시스템 환경 설정> 네트워크를 열어줍니다.
이의 성공률은 네트워크마다 다릅니다. 보안과 같은 많은 요인이 작동하지 않습니다.
읽어 주셔서 감사합니다! 이 작품에 대한 의견이 있거나 개선에 대한 생각 또는 몇 가지 오류를 찾으십시오. 제발 알려주세요! 이 튜토리얼을 지속적으로 개선하기 위해 최선을 다하겠습니다. ?