175 lines
12 KiB
Typst
175 lines
12 KiB
Typst
#import "../style.typ"
|
||
#set heading(numbering: "1.1")
|
||
|
||
= Implementacja <implementacja>
|
||
== Projekt architektury systemu <projekt>
|
||
Przygotowana implementacja bazuje na architekturze Model-View. Jest to podejście, gdzie cała logika biznesowa jest zawarta w modelach, które bezpośrednio są przekazywane do warstwy prezentacji (View). Na tej warstwie jest wykonywane odpowiednie formatowanie danych, gdzie też brane pod uwagę są preferencje zapisu i językowe użytkownika. Model-View to uproszczony wariant popularnej w aplikacjach mobilnych architektury Model-View-ViewModel (MVVM), gdzie ViewModel jest warstwą zajmującą się przekształcaniem modelów biznesowych na gotowe do prezentacji obiekty. Warstwa View wtedy zajmuje się przede wszystkim definiowaniem struktury interfejsu użytkownika oraz logiką związaną z dostępnością (wsparcie dla funkcjonalności czytników ekranów, skalowaniem interfejsu). Przygotowana implementacja nie zawiera złożonej logiki prezentacji ani rozbudowanego graficznego interfejsu użytkownika, więc w celu uproszczenia kodu zdecydowałem się na porzucenie użycia warstwy ViewModelu.
|
||
|
||
Warstwa prezentacji aplikacji opisująca początkowy interfejs użytkownika jest zaimplementowana w `AllNotesScreen`. Ekran jest widoczny dla użytkownika, gdy ustawił swoją nazwę użytkownika w sieci peer-to-peer. Składa się z listy podzielonej na dwie sekcje - notatek których użytkownik jest autorem, oraz notatek do których użytkownik został zaproszony. Nad listą znajduje się przycisk stworzenia nowej notatki. Naciśnięcie na którąkolwiek z istniejących notatek prowadzi do otwarcia jej zawartości. W zależności od tego, czy użytkownik jest właścicielem danej notatki czy współtwórcą, interfejsem do edycji tej notatki są odpowiednio obiekty `NoteEditorScreen` oraz `SharedNoteEditor`. Z ekranu `NoteEditorScreen` możemy przejść do `ManageMembersScreen`, który jest listą użytkowników zaproszonych i możliwych do zaproszenia do edytowania aktualnej notatki.
|
||
|
||
== Model danych
|
||
Podstawowym obiektem reprezentującym notatkę w systemie plików jest `Note`. Składa się on z URL (Universal Resource Locator), czyli ścieżki prowadzącej do pełnej zawartości notatki użytkownika, która jest przechowywana w pliku tekstowym o rozszerzeniu `txt`. Dodatkowo przechowujemy w tym obiekcie jeszcze `name`, które pełni funkcję ułatwionego dostępu do przyjaznej nazwy, jednocześnie będąc nazwą pliku w lokalnym systemie plików oraz przyjazną nazwą prezentowaną w liście notatek do których użytkownik został zaproszony. Obiekt implementuje protokół `Identifiable`, który gwarantuje możliwość identyfikacji obiektu w zbiorze zawierającym wiele jego instancji, np. w tablicy. Ta cecha jest wymagana i wykorzystywana przez SwiftUI, by móc rozróżniać rodzaje zmian na komponentach interfejsów graficznych zawierających wiele kopii takiego obiektu, np. `List` lub `ForEach`. Umożliwia to dokonanie decyzji co do sposobu animacji zmian na ekranie użytkownika, ponieważ framework będzie mógł rozróżnić usunięcie i wstawienie nowego obiektu, od zmiany parametrów tej samej instancji.
|
||
|
||
```swift
|
||
struct Note: Identifiable {
|
||
var id: URL { path }
|
||
|
||
let name: String
|
||
let path: URL
|
||
}
|
||
```
|
||
|
||
`NoteMessage` to obiekt zawierający zawartość notatki, którą wysyłamy do użytkowników, którzy przyjęli zaproszenie do edycji notatki. Znajdziemy w niej pola `SenderID`, które jest identyfikatorem właściciela notatki oraz `content`, czyli właściwą zawartość notatki. Jest ona również używana do wysyłania każdej aktualizacji do wszystkich użytkowników. Obiekt implementuje protokół `Codable`, który jest uniwersalnym interfejsem kodowania danych. Daje to nam wbudowane wsparcie kodowowania do formatów JSON i XML. Mamy możliwość również pisania własnych koderów, które umożliwią zamianę obiektów implementujących `Codable` do wybranych przez nas, innych formatów danych.
|
||
|
||
```swift
|
||
struct NoteMessage: Codable {
|
||
let senderID: String
|
||
let content: String
|
||
}
|
||
```
|
||
|
||
Do reprezentacji zaproszeń stworzyłem obiekt `NoteInvitation`. Przechowuje on informacje potrzebne do obsługi całego procesu zaproszeń między użytkownikami systemu. Składa się z `invitatorID`, który jest wyspecjalizowanym obiektem frameworku `MultipeerConnectivity` i umożliwia identyfikację użytkownika w czasie komunikacji peer to peer. `note` to obiekt reprezentujący notatkę, którą otrzymujemy w zaproszeniu. Składa się on z tytułu i treści, które były aktualne w momencie zapraszania użytkownika. Ostatnim i jednocześnie prywatnym parametrem jest `invitationHandler`, który jest typem funkcji przyjmującej wartość boolowską (prawda/fałsz). Jest on wykorzystywany do wstrzyknięcia logiki akceptacji notatki, która wymaga kilku operacji w logice klasy obsługującej przyjmowanie zaproszeń. Dzięki takiemu podejściu tworzymy luźną zależność do skomplikowanego obiektu na dalszych etapach systemu. `NoteInvitation` zawiera również metody `accept()` oraz `decline()`, które odpowiadają za wykonanie akcji zaproszenia, które pod spodem odpowiednio używają parametru `invitationHandler`, który można wykonać tylko raz, ze względu na specyfikę `Multipeer Connectivity`, a następnie usuwamy go z pamięci.
|
||
|
||
```swift
|
||
struct NoteInvitation: Identifiable {
|
||
struct NoteContent: Codable {
|
||
let title: String
|
||
let noteSnapshot: String
|
||
}
|
||
|
||
var id: MCPeerID { invitatorID }
|
||
var noteName: String { note.title }
|
||
let invitatorID: MCPeerID
|
||
let note: NoteContent
|
||
private var invitationHandler: ((Bool) -> Void)?
|
||
|
||
init(
|
||
invitatorID: MCPeerID,
|
||
note: NoteContent,
|
||
invitationHandler: ((Bool) -> Void)? = nil
|
||
) {
|
||
self.invitatorID = invitatorID
|
||
self.note = note
|
||
self.invitationHandler = invitationHandler
|
||
}
|
||
|
||
mutating func accept() {
|
||
invitationHandler?(true)
|
||
invitationHandler = nil
|
||
}
|
||
|
||
mutating func decline() {
|
||
invitationHandler?(false)
|
||
invitationHandler = nil
|
||
}
|
||
}
|
||
```
|
||
|
||
Ostatnim obiektem jest struktura `Peer`, która jest opisem aktualnego stanu połączenia wykrytego innej instancji systemu w pobliżu użytkownika. Składa się z wartości enumerowanej opisującej stan połączeniao raz identyfikatorem użytkownika w sieci peer to peer. Implementuja ona protokół `Idenfitiable`, by móc zostać poprawnie użyta do rysowania listy dostępnych klientów w pobliżu użytkownika.
|
||
|
||
```swift
|
||
struct Peer: Identifiable {
|
||
enum ConnectionState {
|
||
case available
|
||
case joined
|
||
case rejected
|
||
case invitationPending
|
||
}
|
||
|
||
var id: String { mcPeer.displayName }
|
||
let mcPeer: MCPeerID
|
||
var state: ConnectionState
|
||
}
|
||
```
|
||
|
||
== Warstwa sieciowa i komunikacja P2P
|
||
Całość komunikacji między urządzeniami odbywa się z wykorzystaniem frameworka Multipeer Connectivity. Klient twórcy notatki pełni rolę serwera, a pozostali użytkownicy, po uprzednim zaproszeniu, mogą dołączyć do edycji notatki, wysyłać swoje zmiany jak i odbierać zmiany, które dystrybuuje serwer.
|
||
|
||
== Odkrywanie innych urządzeń
|
||
Obiekt reprezentujący serwer został nazwany `NoteEditingSessionServer`, który dziedziczy właściowści po klasie `NSObject`, która jest uniwersalną implementacją wielu zachowań, które są wymagane od frameworków udostępnianych przez Apple, które zostały napisane w języku Objective-C. Jego konstruktor w przyjmowanych argumentach oczekuje tylko obiektu `OwnPeer`, który będzie wykorzystywany do identyfikacji instancji aplikacji u innych klientów. Sama implementacja konstruktora tworzy nową sesję `MCSession`; obiekt `MCNearbyServiceBrowser`, który odpowiada za wykrywanie pobliskich klientów. Finalnie przypisuje referencję do samego siebie jako parametr `delegate` dla utworzonych `MCSession` i `MCNearbyServiceBrowser`. Pozwala nam to zaimplementować metody, które będą wykorzystywane wewnątrz tych obiektów do komunikacji z innymi użytkownikami. Protokoły delegujące dla wspomnianych obiektów nazywają się odpowiednio `MCSessionDelegate` oraz `MCNearbyServiceBrowserDelegate`. Moja implementacja tych protokołów zostanie przedstawiona w dalszej części pracy.
|
||
|
||
```swift
|
||
init(peer: OwnPeer) {
|
||
ownPeer = peer
|
||
browser = .init(peer: peer.peer, serviceType: "peered")
|
||
session = .init(peer: peer.peer, securityIdentity: nil, encryptionPreference: .required)
|
||
super.init() // wykonuje pozostałą część
|
||
browser.delegate = self //
|
||
session.delegate = self
|
||
}
|
||
```
|
||
|
||
W momencie, gdy autor notatki otworzy ekran edycji, wykonuje się metoda `startServer()`, która wywołuje metodę `startBrowsingForPeers()` obiektu `MCNearbyServiceBrowser`. Opuszczenie ekranu edycji wywołuje metodę `stopServer()`, która wywołuje analogiczną metodę `stopBrowsingForPeers()` oraz zatrzymuje sesję poprzez wywołanie metody `disconnect()` obiektu `MCSession`.
|
||
|
||
```swift
|
||
func startServer() {
|
||
browser.startBrowsingForPeers()
|
||
}
|
||
|
||
func stopServer() {
|
||
browser.stopBrowsingForPeers()
|
||
session.disconnect()
|
||
}
|
||
```
|
||
|
||
Obiekt `browser` w momencie wykrycia nowego użytkownika w pobliżu, wywołuje naszą metodę o nazwie `browser()`, która przyjmuje wszystkie potrzebne informacje o znalezionym użytkowniku. Implementacja mojego systemu następnie upewnia się czy odkryty użytkownik nie jest jednocześnie autorem notatki, co jest znanym błędem w Multipeer Connectivity. Następnie po udanej weryfikacji dodajemy nowy obiekt dostępnego użytkownika do tablicy na podstawie której jest budowany interfejs z listą dostępnych użytkowników.
|
||
|
||
```swift
|
||
func browser(
|
||
_ browser: MCNearbyServiceBrowser,
|
||
foundPeer peerID: MCPeerID,
|
||
withDiscoveryInfo info: [String: String]?
|
||
) {
|
||
guard !visiblePeers.contains(where: { $0.mcPeer == peerID }) && peerID.displayName != ownPeer.peer.displayName else { return }
|
||
DispatchQueue.main.async {
|
||
self.visiblePeers.append(Peer(mcPeer: peerID, state: .available))
|
||
}
|
||
}
|
||
```
|
||
|
||
W sytuacji gdy użytkownik straci połączenie z połączonym klientem, następuje usunięcie jego identyfikatora z listy, poprzez wywołanie metody o takiej samej nazwie, ale z parametrami wskazującymi na scenariusz zgubienia klienta.
|
||
|
||
```swift
|
||
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
|
||
DispatchQueue.main.async {
|
||
guard let peerIdx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||
self.visiblePeers.remove(at: peerIdx)
|
||
}
|
||
}
|
||
```
|
||
|
||
Każda modyfikacja obiektów klasy w tym wypadku musi zostać wywołana na tym samym wątku, ponieważ Multipeer Connectivity nie gwarantuje, że kod będzie się wykonał zawsze na tym samym wątku. Wybrałem wątek główny, ze względu na bezpośrednie użycie właściwości klasy wewnątrz obiektów odpowiedzialnych za budowę interfejsu użytkownika.
|
||
|
||
O stanie połączenia z innymi klientami mój system jest informowany przez wykonanie metody session z parametrami zawierającymi informację o stanie, który jest reprezentowany przez typ enumeracji, obiekt sesji oraz identyfikator klienta, których ten stan połączenia dotyczy. Na podstawie tych argumentów, aplikacja aktualizuje tablicę `visiblePeers`.
|
||
|
||
```swift
|
||
func session(
|
||
_ session: MCSession,
|
||
peer peerID: MCPeerID,
|
||
didChange state: MCSessionState
|
||
) {
|
||
DispatchQueue.main.async {
|
||
guard let idx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return }
|
||
switch state {
|
||
case .connected:
|
||
self.visiblePeers[idx].state = .joined
|
||
case .notConnected:
|
||
let currentState = self.visiblePeers[idx].state
|
||
if currentState == .invitationPending || currentState == .joined {
|
||
self.visiblePeers[idx].state = .rejected
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
== Transportowanie danych
|
||
== Algorytm rozwiązywania konfliktów
|
||
== Środowisko developerskie i stack technologiczny
|
||
== Implementacja logiki P2P
|
||
== Interfejs użytkownika
|
||
== Napotkane wyzwania implementacyjne i rozwiązania
|
||
== Ograniczenia środowisk iOS/macOS
|