Leer iOS best practices door een eenvoudige recepten-app te bouwen

Bron: ChefStep

Inhoudsopgave

  • Ermee beginnen
  • Xcode en Swift-versie
  • Minimale iOS-versie om te ondersteunen
  • Het Xcode-project organiseren
  • Recepten app structuur
  • Code conventies
  • Documentatie
  • Markeringssecties markeren
  • Bron controle
  • afhankelijkheden
  • In het project stappen
  • API
  • Lanceer scherm
  • App pictogram
  • Linting code met SwiftLint
  • Type-veilige bron
  • Laat me de code zien
  • Ontwerp van het model
  • Betere navigatie met FlowController
  • Automatische lay-out
  • architectuur
  • Massive View Controller
  • Toegangscontrole
  • Luie eigenschappen
  • Codefragmenten
  • Netwerken
  • Hoe netwerkcode te testen
  • Cache implementeren voor offline ondersteuning
  • Hoe Cache te testen
  • Afbeeldingen op afstand laden
  • Het laden van afbeeldingen gemakkelijker maken voor UIImageView
  • Algemene gegevensbron voor UITableView en UICollectionView
  • Controller en weergave
  • Verantwoordelijkheden afhandelen met een kind View Controller
  • Samenstelling en afhankelijkheid injectie
  • App Transportbeveiliging
  • Een aangepaste Scrollable-weergave
  • Zoekfunctionaliteit toevoegen
  • De context van de presentatie begrijpen
  • Zoekacties debounderen
  • Debouncing testen met omgekeerde verwachting
  • Gebruikersinterface testen met UITests
  • Hoofddraadbeschermer
  • Prestaties en problemen meten
  • Prototyping met Speeltuin
  • Waar te gaan vanaf hier

Ik begon met de ontwikkeling van iOS toen iOS 7 was aangekondigd. En ik heb een beetje geleerd, door te werken, advies van collega's en de iOS-gemeenschap.

In dit artikel wil ik veel goede praktijken delen door het voorbeeld van een eenvoudige recepten-app te nemen. De broncode staat op GitHub Recepten.

De app is een traditionele hoofddetailtoepassing die een lijst met recepten toont met hun gedetailleerde informatie.

Er zijn duizenden manieren om een ​​probleem op te lossen, en de manier waarop een probleem wordt aangepakt, hangt ook af van de persoonlijke smaak. Hopelijk leer je in dit artikel iets nuttigs - ik heb veel geleerd toen ik dit project deed.

Ik heb links naar enkele zoekwoorden toegevoegd waarvan ik dacht dat verder lezen nuttig zou zijn. Dus bekijk ze zeker. Alle feedback is welkom.

Dus laten we beginnen…

Hier is een overzicht op hoog niveau van wat u gaat bouwen.

Ermee beginnen

Laten we beslissen over de tool- en projectinstellingen die we gebruiken.

Xcode en Swift-versie

Op WWDC 2018 introduceerde Apple Xcode 10 met Swift 4.2. Op het moment van schrijven is Xcode 10 echter nog in bèta 5. Laten we het dus hebben bij de stabiele Xcode 9 en Swift 4.1. Xcode 4.2 heeft een aantal coole functies - je kunt ermee spelen via deze geweldige speelplaats. Het introduceert geen grote veranderingen van Swift 4.1, dus we kunnen onze app in de nabije toekomst gemakkelijk bijwerken indien nodig.

U moet de Swift-versie instellen in de Project-instelling in plaats van de doelinstellingen. Dit betekent dat alle doelen in het project dezelfde Swift-versie (4.1) delen.

Minimale iOS-versie om te ondersteunen

Vanaf de zomer van 2018 is iOS 12 in openbare bèta 5 en kunnen we ons niet richten op iOS 12 zonder Xcode 10. In dit bericht gebruiken we Xcode 9 en de basis-SDK is iOS 11. Afhankelijk van de vereiste en gebruikersbasissen, sommige apps moeten oude iOS-versies ondersteunen. Hoewel iOS-gebruikers de neiging hebben om nieuwe iOS-versies sneller te gebruiken dan degenen die Android gebruiken, zijn er sommige die bij oude versies blijven. Volgens het advies van Apples moeten we de twee meest recente versies ondersteunen, namelijk iOS 10 en iOS 11. Zoals gemeten door de App Store op 31 mei 2018, gebruikt slechts 5% van de gebruikers iOS 9 en eerder.

Door ons te richten op nieuwe iOS-versies kunnen we profiteren van de voordelen van nieuwe SDK's, die de technici van Apple elk jaar verbeteren. De Apple-website voor ontwikkelaars heeft een verbeterde weergave van het wijzigingslogboek. Het is nu gemakkelijker om te zien wat is toegevoegd of gewijzigd.

Idealiter hebben we analyses nodig over hoe gebruikers onze app gebruiken om te bepalen wanneer we ondersteuning voor oude iOS-versies laten vallen.

Het Xcode-project organiseren

Wanneer we het nieuwe project maken, selecteert u zowel 'Eenheidstests opnemen' als 'UI-tests opnemen' omdat het een aanbevolen methode is om tests vroeg te schrijven. Recente wijzigingen in het XCTest-framework, vooral in UI-tests, maken testen een fluitje van een cent en zijn redelijk stabiel.

Neem een ​​pauze voordat u nieuwe bestanden aan het project toevoegt en denk na over de structuur van uw app. Hoe willen we de bestanden organiseren? We hebben een paar opties. We kunnen bestanden organiseren op functie / module of rol / typen. Elk heeft zijn voor- en nadelen en ik zal ze hieronder bespreken.

Op rol / type:

  • Voordelen: er wordt minder nagedacht over waar bestanden moeten worden geplaatst. Het is ook eenvoudiger om scripts of filters toe te passen.
  • Nadelen: het is moeilijk om te correleren of we meerdere bestanden willen vinden die verband houden met dezelfde functie. Het zou ook tijd kosten om bestanden te reorganiseren als we ze in de toekomst tot herbruikbare componenten willen maken.

Op functie / module

  • Voordelen: het maakt alles modulair en moedigt samenstelling aan.
  • Nadelen: het kan rommelig worden wanneer veel bestanden van verschillende typen worden gebundeld.

Modulair blijven

Persoonlijk probeer ik mijn code zoveel mogelijk te ordenen op functies / componenten. Dit maakt het eenvoudiger om te identificeren gerelateerde code te identificeren en nieuwe functies in de toekomst gemakkelijker toe te voegen. Het beantwoordt de vraag "Wat doet deze app?" In plaats van "Wat is dit bestand?" Hier is een goed artikel hierover.

Een goede vuistregel is om consistent te blijven, ongeacht de structuur die u kiest.

Recepten app structuur

Het volgende is de app-structuur die onze recepten-app gebruikt:

Bron

Bevat broncodebestanden, opgesplitst in componenten:

  • Kenmerken: de belangrijkste functies in de app
  • Home: het startscherm met een lijst met recepten en een open zoekopdracht
  • Lijst: toont een lijst met recepten, inclusief het opnieuw laden van een recept en het tonen van een lege weergave wanneer een recept niet bestaat
  • Zoeken: omgaan met zoeken en debouncen
  • Detail: toont gedetailleerde informatie

Bibliotheek

Bevat de kerncomponenten van onze applicatie:

  • Flow: bevat FlowController om flows te beheren
  • Adapter: generieke gegevensbron voor UICollectionView
  • Uitbreiding: handige uitbreidingen voor veelgebruikte bewerkingen
  • Model: het model in de app, ontleed uit JSON

hulpbron

Bevat plist-, resource- en Storyboard-bestanden.

Code conventies

Ik ben het eens met de meeste stijlgidsen in raywenderlich / swift-style-guide en github / swift-style-guide. Deze zijn eenvoudig en redelijk te gebruiken in een Swift-project. Bekijk ook de officiële API-ontwerprichtlijnen van het Swift-team bij Apple over het schrijven van betere Swift-code.

Welke stijlgids u ook kiest te volgen, duidelijkheid van codes moet uw belangrijkste doel zijn.

Inspringen en de tab-ruimte oorlog is een gevoelig onderwerp, maar nogmaals, het hangt af van smaak. Ik gebruik vier spaties inspringen in Android-projecten en twee spaties in iOS en React. In deze Recepten-app volg ik een consistente en gemakkelijk te redeneren inspringing, waarover ik hier en hier heb geschreven.

Documentatie

Goede code moet zichzelf duidelijk uitleggen, zodat u geen opmerkingen hoeft te schrijven. Als een deel van de code moeilijk te begrijpen is, is het goed om even te pauzeren en het te wijzigen naar sommige methoden met beschrijvende namen, zodat het stuk code duidelijker te begrijpen is. Ik vind echter dat documentatieklassen en -methoden ook goed zijn voor je collega's en je toekomstige zelf. Volgens de Swift API-ontwerprichtlijnen,

Schrijf een documentatiecommentaar voor elke aangifte. Inzichten verkregen door het schrijven van documentatie kunnen een grote impact hebben op uw ontwerp, dus stel het niet uit.

Het is heel eenvoudig om reactiesjabloon /// in Xcode te genereren met Cmd + Alt + /. Als u van plan bent om uw code te transformeren naar een framework om in de toekomst met anderen te delen, kunnen tools zoals jazzy documentatie genereren zodat andere mensen kunnen volgen.

Markeringssecties markeren

Het gebruik van MARK kan nuttig zijn om secties code te scheiden. Het groepeert functies ook mooi in de navigatiebalk. U kunt ook extensiegroepen, gerelateerde eigenschappen en methoden gebruiken.

Voor een eenvoudige UIViewController kunnen we mogelijk de volgende MARK's definiëren:

// MARK: - Init
// MARK: - Bekijk levenscyclus
// MARK: - Setup
// MARK: - Actie
// MARK: - Gegevens

Bron controle

Git is nu een populair broncontrolesysteem. We kunnen het sjabloon .gitignore-bestand van gitignore.io/api/swift gebruiken. Er zijn zowel voor- als nadelen bij het inchecken van afhankelijkhedenbestanden (CocoaPods en Carthage). Het hangt van uw project af, maar ik neig ertoe om geen afhankelijkheden (knooppuntmodules, Carthago, Pods) in bronbeheer te begaan om de codebasis niet te overbelasten. Het maakt ook het beoordelen van Pull-aanvragen eenvoudiger.

Of u nu in de map Pods incheckt of niet, Podfile en Podfile.lock moeten altijd onder versiebeheer worden gehouden.

Ik gebruik zowel iTerm2 om opdrachten uit te voeren als Bronboom om vertakkingen en enscenering te bekijken.

afhankelijkheden

Ik heb frameworks van derden gebruikt, en ook veel gemaakt en bijgedragen aan open source. Het gebruik van een framework geeft je een boost in het begin, maar het kan je ook in de toekomst veel beperken. Er kunnen enkele triviale veranderingen zijn die heel moeilijk te omzeilen zijn. Hetzelfde gebeurt bij het gebruik van SDK's. Mijn voorkeur gaat uit naar actieve open source frameworks. Lees de broncode en controleer kaders zorgvuldig en overleg met uw team als u van plan bent ze te gebruiken. Een beetje extra voorzichtigheid kan geen kwaad.

In deze app probeer ik zo min mogelijk afhankelijkheden te gebruiken. Net genoeg om te demonstreren hoe afhankelijkheden te beheren. Sommige ervaren ontwikkelaars geven misschien de voorkeur aan Carthage, een afhankelijkheidsmanager omdat het u volledige controle geeft. Hier kies ik CocoaPods omdat het gemakkelijk te gebruiken is en het tot nu toe prima heeft gewerkt.

Er is een bestand met de naam .swift-versie van waarde 4.1 in de root van het project om CocoaPods te vertellen dat dit project Swift 4.1 gebruikt. Dit ziet er eenvoudig uit, maar het heeft me behoorlijk wat tijd gekost om erachter te komen.

In het project stappen

Laten we enkele startafbeeldingen en pictogrammen maken om het project een mooie uitstraling te geven.

API

De eenvoudige manier om iOS-netwerken te leren, is via openbare gratis API-services. Hier gebruik ik food2fork. U kunt zich registreren voor een account op http://food2fork.com/about/api. Er zijn veel andere geweldige API's in deze public-api-repository.

Het is goed om uw inloggegevens op een veilige plaats te bewaren. Ik gebruik 1Password om mijn wachtwoorden te genereren en op te slaan.

Voordat we beginnen met coderen, laten we spelen met de API's om te zien welke soorten aanvragen ze nodig hebben en welke antwoorden ze retourneren. Ik gebruik de Insomnia-tool om API-reacties te testen en analyseren. Het is open source, gratis en werkt geweldig.

Lanceer scherm

De eerste indruk is belangrijk, net als het startscherm. De voorkeur gaat uit naar LaunchScreen.storyboard in plaats van een statische startafbeelding.

Als u een startafbeelding wilt toevoegen aan Activacatalogus, opent u LaunchScreen.storyboard, voegt u UIImageView toe en speldt u deze aan de randen van UIView. We moeten de afbeelding niet vastmaken aan de veilige zone omdat we willen dat de afbeelding op volledig scherm wordt weergegeven. Deselecteer ook alle marges in de beperkingen van de Auto Layout. Stel de contentMode van de UIImageView in als Aspect Fill zodat deze zich uitstrekt met de juiste beeldverhouding.

Configureer de lay-out in LaunchScreen.

App pictogram

Een goede gewoonte is om alle benodigde app-pictogrammen te bieden voor elk apparaat dat u ondersteunt, en ook voor plaatsen zoals Melding, Instellingen en Springplank. Zorg ervoor dat elke afbeelding geen transparante pixels heeft, anders resulteert dit in een zwarte achtergrond. Deze tip komt van Human Interface Guidelines - App Icon.

Houd de achtergrond eenvoudig en vermijd transparantie. Zorg ervoor dat uw pictogram ondoorzichtig is en maak de achtergrond niet rommelig. Geef het een eenvoudige achtergrond zodat andere app-pictogrammen in de buurt niet worden overweldigd. U hoeft niet het hele pictogram met inhoud te vullen.

We moeten vierkante afbeeldingen ontwerpen met een grootte die groter is dan 1024 x 1024, zodat elke afbeelding kan worden verkleind naar kleinere afbeeldingen. Je kunt dit doen met de hand, script of deze kleine IconGenerator-app gebruiken die ik heb gemaakt.

De IconGenerator-app kan pictogrammen voor iOS genereren in iPhone-, iPad-, macOS- en watchOS-apps. Het resultaat is de AppIcon.appiconset die we rechtstreeks naar de activacatalogus kunnen slepen. Asset Catalogue is dé manier voor moderne Xcode-projecten.

Linting code met SwiftLint

Ongeacht welk platform we ontwikkelen, het is goed om een ​​achterloper te hebben om consistente conventies af te dwingen. De meest populaire tool voor Swift-projecten is SwiftLint, gemaakt door de geweldige mensen bij Realm.

Om het te installeren, voegt u pod 'SwiftLint', '~> 0,25' toe aan de Podfile. Het is ook een goede gewoonte om de versie van de afhankelijkheden op te geven, zodat pod-installatie niet per ongeluk wordt bijgewerkt naar een belangrijke versie die uw app zou kunnen breken. Voeg vervolgens een .swiftlint.yml toe met de configuratie van uw voorkeur. Een voorbeeldconfiguratie is hier te vinden.

Voeg ten slotte een nieuwe scriptzin toe om Swiftlint na het compileren uit te voeren.

Type-veilige bron

Ik gebruik R.swift om bronnen veilig te beheren. Het kan type-veilige klassen genereren voor toegang tot lettertype, lokaliseerbare tekenreeksen en kleuren. Wanneer we de bestandsnamen van bronnen wijzigen, krijgen we compileerfouten in plaats van een impliciete crash. Dit voorkomt dat we informatie afleiden die actief in gebruik is.

imageView.image = R.image.notFound ()

Laat me de code zien

Laten we de code onder de loep nemen, te beginnen met het model, stroomregelaars en serviceklassen.

Ontwerp van het model

Het klinkt misschien saai, maar klanten zijn gewoon een mooiere manier om de API-reactie weer te geven. Het model is misschien wel het meest elementaire en we gebruiken het veel in de app. Het speelt zo'n belangrijke rol, maar er kunnen enkele voor de hand liggende bugs zijn met betrekking tot misvormde modellen en aannames over hoe een model moet worden ontleed die moeten worden overwogen.

We moeten testen voor elk model van de app. In het ideale geval hebben we geautomatiseerde testen van modellen op basis van API-antwoorden nodig voor het geval het model van de backend is veranderd.

Vanaf Swift 4.0 kunnen we ons model aanpassen aan Codable om eenvoudig te serialiseren naar en van JSON. Ons model moet onveranderlijk zijn:

struct Recept: Codeerbaar {
  laat uitgever: String
  let url: URL
  let sourceUrl: String
  let id: String
  let title: String
  let imageUrl: String
  laat socialRank: dubbel
  laat publisherUrl: URL
enum CodingKeys: String, CodingKey {
    zaakuitgever
    case url = "f2f_url"
    case sourceUrl = "source_url"
    case id = "recept_id"
    zaak titel
    case imageUrl = "image_url"
    case socialRank = "social_rank"
    case publisherUrl = "publisher_url"
  }
}

We kunnen een aantal testframes gebruiken als u van mooie syntaxis of een RSpec-stijl houdt. Sommige testframes van derden kunnen problemen hebben. Ik vind XCTest goed genoeg.

XCTest importeren
@testable Recepten importeren
class RecipesTests: XCTestCase {
  func testParsing () gooit {
    let json: [String: Any] = [
      "publisher": "Two Peas and their Pod",
      "f2f_url": "http://food2fork.com/view/975e33",
      "title": "No-Bake Chocolade Pindakaas Pretzel Cookies",
      "source_url": "http://www.twopeasandtheirpod.com/no-bake-chocolate-peanut-butter-pretzel-cookies/",
      "cept_id ":" 975e33 ",
      "image_url": "http://static.food2fork.com/NoBakeChocolatePeanutButterPretzelCookies44147.jpg",
      "social_rank": 99.99999999999974,
      "publisher_url": "http://www.twopeasandtheirpod.com"
    ]
let data = probeer JSONSerialization.data (withJSONObject: json, options: [])
    let decoder = JSONDecoder ()
    let recept = probeer decoder.decode (Recept. zelf, van: data)
XCTAssertEqual (recept. Titel, "No-Bake Chocolade Pindakaas Pretzel Cookies")
    XCTAssertEqual (recept.id, "975e33")
    XCTAssertEqual (recept.url, URL (string: "http://food2fork.com/view/975e33")!)
  }
}

Betere navigatie met FlowController

Vroeger gebruikte ik Compass als een routing-engine in mijn projecten, maar na verloop van tijd merkte ik dat het schrijven van eenvoudige routing-code ook werkt.

De FlowController wordt gebruikt om veel aan UIViewController gerelateerde componenten te beheren voor een gemeenschappelijke functie. Misschien wil je FlowController en Coordinator lezen voor andere gebruikssituaties en voor een beter begrip.

Er is de AppFlowController die het beheren van rootViewController beheert. Voor nu start het de ReceptFlowController.

window = UIWindow (frame: UIScreen.main.bounds)
venster? .rootViewController = appFlowController
venster? .makeKeyAndVisible ()
appFlowController.start ()

RecipeFlowController beheert (in feite is het) de UINavigationController, die het pushen van HomeViewController, RecipesDetailViewController, SafariViewController afhandelt.

laatste klasse RecipeFlowController: UINavigationController {
  /// Start de stroom
  func start () {
    let service = RecipesService (netwerken: NetworkService ())
    let controller = HomeViewController (receptenService: service)
    viewControllers = [controller]
    controller.select = {[zwak zelf] recept in
      self? .startDetail (recept: recept)
    }
  }
private func startDetail (recept: Recept) {}
  private func startWeb (url: URL) {}
}

De UIViewController kan delegeren of afsluiten gebruiken om FlowController op de hoogte te brengen van wijzigingen of volgende schermen in de stroom. Voor gedelegeerde kan het nodig zijn om te controleren wanneer er twee instanties van dezelfde klasse zijn. Hier gebruiken we sluiter voor eenvoud.

Automatische lay-out

Auto Layout bestaat al sinds iOS 5, het wordt elk jaar beter. Hoewel sommige mensen er nog steeds een probleem mee hebben, vooral vanwege verwarrende beperkingen en prestaties, maar persoonlijk vind ik Auto Layout goed genoeg.

Ik probeer zoveel mogelijk Auto Layout te gebruiken om een ​​adaptieve UI te maken. We kunnen bibliotheken zoals Anchors gebruiken om declaratieve en snelle Auto Layout te doen. In deze app gebruiken we echter alleen NSLayoutAnchor, omdat deze afkomstig is van iOS 9. De onderstaande code is geïnspireerd op Constraint. Onthoud dat Auto Layout in zijn eenvoudigste vorm het schakelen tussen translatesAutoresizingMaskIntoConstraints en het activeren van isActive constraints omvat.

extensie NSLayoutConstraint {
  statische func activeren (_ beperkingen: [NSLayoutConstraint]) {
    constraints.forEach {
      ($ 0.firstItem as? UIView) ?. translatesAutoresizingMaskIntoConstraints = false
      $ 0.isActive = true
    }
  }
}

Er zijn eigenlijk veel andere layout-engines beschikbaar op GitHub. Bekijk de LayoutFrameworkBenchmark om een ​​idee te krijgen welke geschikt zou zijn om te gebruiken.

architectuur

Architectuur is waarschijnlijk het meest gehypete en besproken onderwerp. Ik ben een fan van het verkennen van architecturen, je kunt hier meer berichten en frameworks over verschillende architecturen bekijken.

Voor mij definiëren alle architecturen en patronen de rollen voor elk object en hoe ze te verbinden. Onthoud deze richtlijnen voor uw architectuurkeuze:

  • inkapselen wat varieert
  • voorkeur voor samenstelling boven erfenis
  • programma om te koppelen, niet om te implementeren

Na het spelen met veel verschillende architecturen, met en zonder Rx, kwam ik erachter dat eenvoudige MVC goed genoeg is. In dit eenvoudige project is er alleen UIViewController met logica ingekapseld in helpserviceklassen,

Massive View Controller

Je hebt misschien mensen horen grappen maken over hoe enorm UIViewController is, maar in werkelijkheid is er geen enorme view-controller. Wij schrijven alleen slechte code. Er zijn echter manieren om het af te slanken.

In de recepten-app die ik gebruik,

  • Service om in de viewcontroller te injecteren om een ​​enkele taak uit te voeren
  • Algemene weergave om de weergave te verplaatsen en de aangifte te regelen naar de weergavenlaag
  • Kinderweergavecontroller om kinderweergavecontrollers samen te stellen om meer functies te bouwen

Hier is een heel goed artikel met 8 tips om grote controllers af te slanken.

Toegangscontrole

De SWIFT-documentatie vermeldt dat "toegangscontrole de toegang beperkt tot delen van uw code vanuit code in andere bronbestanden en modules. Met deze functie kunt u de implementatiegegevens van uw code verbergen en een voorkeursinterface opgeven waarmee die code kan worden geopend en gebruikt. "

Alles moet standaard privé en definitief zijn. Dit helpt ook de compiler. Wanneer we een openbaar eigendom zien, moeten we ernaar zoeken in het hele project voordat we er verder iets mee doen. Als het onroerend goed alleen binnen een klasse wordt gebruikt, betekent het privé maken dat we ons geen zorgen hoeven te maken als het elders breekt.

Verklaar eigenschappen waar mogelijk als definitief.

laatste klas HomeViewController: UIViewController {}

Geef eigenschappen aan als privé of ten minste privé (ingesteld).

laatste les RecipeDetailView: UIView {
  private let scrollableView = ScrollableView ()
  privé (set) lui var imageView: UIImageView = self.makeImageView ()
}

Luie eigenschappen

Voor eigenschappen die op een later tijdstip kunnen worden geraadpleegd, kunnen we ze als lui verklaren en sluiting kunnen gebruiken voor een snelle constructie.

laatste klas RecipeCell: UICollectionViewCell {
  private (set) lazy var containerView: UIView = {
    let view = UIView ()
    view.clipsToBounds = waar
    view.layer.cornerRadius = 5
    view.backgroundColor = Color.main.withAlphaComponent (0.4)
terugblik
  } ()
}

We kunnen ook make-functies gebruiken als we van plan zijn dezelfde functie voor meerdere eigenschappen opnieuw te gebruiken.

laatste les RecipeDetailView: UIView {
  privé (set) lui var imageView: UIImageView = self.makeImageView ()
private func makeImageView () -> UIImageView {
    let imageView = UIImageView ()
    imageView.contentMode = .scaleAspectFill
    imageView.clipsToBounds = true
    beeldweergave bekijken
  }
}

Dit komt ook overeen met het advies van Strive for Fluent Usage.

Begin namen van fabrieksmethoden met "make", bijvoorbeeld x.makeIterator ().

Codefragmenten

Sommige codesyntaxis is moeilijk te onthouden. Overweeg het gebruik van codefragmenten om automatisch code te genereren. Dit wordt ondersteund door Xcode en is de voorkeursmanier van Apple-technici wanneer ze demonstreren.

indien # beschikbaar (iOS 11, *) {
  viewController.navigationItem.searchController = searchController
  viewController.navigationItem.hidesSearchBarWhenScrolling = false
} anders {
  viewController.navigationItem.titleView = searchController.searchBar
}

Ik heb een repo gemaakt met enkele handige Swift-fragmenten die velen graag gebruiken.

Netwerken

Netwerken in Swift is een soort opgelost probleem. Er zijn vervelende en foutgevoelige taken, zoals het ontleden van HTTP-antwoorden, het verwerken van wachtrijen met verzoeken, het verwerken van parameterquery's. Ik heb bugs gezien over PATCH-aanvragen, lagere HTTP-methoden, ... We kunnen gewoon Alamofire gebruiken. U hoeft hier geen tijd te verspillen.

Voor deze app, omdat het eenvoudig is en om onnodige afhankelijkheden te voorkomen. We kunnen URLSession gewoon rechtstreeks gebruiken. Een resource bevat meestal URL, pad, parameters en de HTTP-methode.

struct Resource {
  let url: URL
  let path: String?
  laat httpMethod: String
  let parameters: [String: String]
}

Een eenvoudige netwerkservice kan Resource gewoon ontleden naar URLRequest en URLSession laten uitvoeren

laatste klas NetworkService: Netwerken {
  @discardableResult func fetch (resource: Resource, voltooiing: @escaping (Data?) -> Void) -> URLSessionTask? {
    guard let request = makeRequest (resource: resource) else {
      afronding (nil)
      retour nul
    }
let task = session.dataTask (met: request, completeringHandler: {data, _, error in
      bewaker let data = data, error == nul anders {
        afronding (nil)
        terugkeer
      }
afronding (data)
    })
task.resume ()
    taak teruggeven
  }
}

Gebruik afhankelijkheid injectie. Beller toestaan ​​om URLSessionConfiguration op te geven. Hier maken we gebruik van Swift standaard parameter om de meest voorkomende optie te bieden.

init (configuratie: URLSessionConfiguration = URLSessionConfiguration.default) {
  self.session = URLSession (configuratie: configuratie)
}

Ik gebruik ook URLQueryItem dat afkomstig was van iOS 8. Het maakt parseerparameters om items te bevragen leuk en minder vervelend.

Hoe netwerkcode te testen

We kunnen URLProtocol en URLCache gebruiken om een ​​stub toe te voegen voor netwerkreacties of we kunnen kaders gebruiken zoals Mockingjay die URLSessionConfiguration doet slingeren.

Ik gebruik zelf liever het protocol om te testen. Door het protocol te gebruiken, kan de test een nepverzoek maken om een ​​stubrespons te geven.

protocol netwerken {
  @discardableResult func fetch (resource: Resource, voltooiing: @escaping (Data?) -> Void) -> URLSessionTask?
}
laatste klas MockNetworkService: Netwerken {
  laat data: Data
  init (fileName: String) {
    let bundle = Bundle (voor: MockNetworkService.self)
    let url = bundle.url (forResource: fileName, withExtension: "json")!
    self.data = probeer! Gegevens (inhoud van: url)
  }
func fetch (resource: Resource, completering: @escaping (Data?) -> Void) -> URLSessionTask? {
    afronding (data)
    terug nul
  }
}

Cache implementeren voor offline ondersteuning

Ik droeg veel bij en gebruikte een bibliotheek genaamd Cache. Wat we nodig hebben van een goede cachebibliotheek is geheugen en schijfcache, geheugen voor snelle toegang, schijf voor persistentie. Wanneer we opslaan, slaan we op in zowel geheugen als schijf. Wanneer we laden, als geheugencache mislukt, laden we van schijf en werken we het geheugen vervolgens opnieuw bij. Er zijn veel geavanceerde onderwerpen over cache, zoals opschonen, vervaldatum, toegangsfrequentie. Lees hier meer over.

In deze eenvoudige app is een cache-serviceklasse van eigen bodem voldoende en een goede manier om te leren hoe caching werkt. Alles in Swift kan worden geconverteerd naar Data, dus we kunnen Data gewoon opslaan in cache. Swift 4 Codable kan objecten serialiseren naar gegevens.

De onderstaande code laat ons zien hoe FileManager te gebruiken voor schijfcache.

/// Gegevens opslaan en laden in geheugen en schijfcache
laatste klas CacheService {
/// Voor ophalen of laden van gegevens in het geheugen
  private let memory = NSCache  ()
/// De pad-URL die bestanden in de cache bevat (mp3-bestanden en afbeeldingsbestanden)
  private let diskPath: URL
/// Voor controle van bestand of map bestaat in een gespecificeerd pad
  privé let fileManager: FileManager
/// Zorg ervoor dat alle bewerkingen serieel worden uitgevoerd
  private let serialQueue = DispatchQueue (label: "Recepten")
init (fileManager: FileManager = FileManager.default) {
    self.fileManager = fileManager
    Doen {
      let documentDirectory = probeer fileManager.url (
        voor: .documentDirectory,
        in: .userDomainMask,
        geschikt voor: nihil,
        create: true
      )
      diskPath = documentDirectory.appendingPathComponent ("Recepten")
      probeer createDirectoryIfNeeded (), probeer
    } vangst {
      fatale fout()
    }
  }
func save (gegevens: gegevens, sleutel: tekenreeks, voltooiing: (() -> ongeldig)? = nul) {
    let key = MD5 (key)
serialQueue.async {
      self.memory.setObject (gegevens als NSData, forKey: key as NSString)
      Doen {
        probeer data.write (to: self.filePath (key: key))
        voltooiing?()
      } vangst {
        druk (fout)
      }
    }
  }
}

Om misvormde en zeer lange bestandsnamen te voorkomen, kunnen we ze hashen. Ik gebruik MD5 van SwiftHash, wat een dood eenvoudig gebruik geeft let key = MD5 (key).

Hoe Cache te testen

Omdat ik Cache-bewerkingen asynchroon ontwerp, moeten we testverwachtingen gebruiken. Vergeet niet om de status voor elke test te resetten, zodat de vorige teststatus de huidige test niet verstoort. De verwachting in XCTestCase maakt het testen van asynchrone code eenvoudiger dan ooit.

class CacheServiceTests: XCTestCase {
  let service = CacheService ()
func setUp () vervangen
    super.setUp ()
proberen? service.clear ()
  }
func testClear () {
    let expectation = self.expectation (omschrijving: #functie)
    let string = "Hallo wereld"
    let data = string.data (met: .utf8)!
service.save (data: data, key: "key", voltooiing: {
      proberen? self.service.clear ()
      self.service.load (key: "key", voltooiing: {
        XCTAssertNil ($ 0)
        expectation.fulfill ()
      })
    })
wacht (op: [verwachting], time-out: 1)
  }
}

Afbeeldingen op afstand laden

Ik draag ook bij aan Denkbeeldig, dus ik weet een beetje hoe het werkt. Voor externe afbeeldingen moeten we deze downloaden en opslaan in de cache, en de cachesleutel is meestal de URL van de externe afbeelding.

Laten we in onze recipese app een eenvoudige ImageService bouwen op basis van onze NetworkService en CacheService. Kortom, een afbeelding is gewoon een netwerkbron die we downloaden en in de cache opslaan. We geven de voorkeur aan samenstelling, dus we nemen NetworkService en CacheService op in ImageService.

/// Controleer lokale cache en haal externe afbeelding op
laatste klas ImageService {
private let networkService: netwerken
  private let cacheService: CacheService
  private var taak: URLSessionTask?
init (networkService: Networking, cacheService: CacheService) {
    self.networkService = networkService
    self.cacheService = cacheService
  }
}

We hebben meestal UICollectionViewen UITableView-cellen met UIImageView. En omdat cellen opnieuw worden gebruikt, moeten we elke bestaande verzoektaak annuleren voordat we een nieuw verzoek indienen.

func fetch (url: URL, completering: @escaping (UIImage?) -> Void) {
  // Annuleer bestaande taak indien aanwezig
  taak? .cancel ()
// Probeer laden vanuit cache
  cacheService.load (sleutel: url.absoluteString, voltooiing: {[zwakke zelf] cacheData in
    if let data = cachedData, let image = UIImage (data: data) {
      DispatchQueue.main.async {
        afronding (beeld)
      }
    } anders {
      // Probeer een aanvraag aan te vragen bij het netwerk
      let resource = Resource (url: url)
      self? .task = self? .networkService.fetch (resource: resource, completering: {networkData in
        if let data = networkData, let image = UIImage (data: data) {
          // Opslaan in cache
          self? .cacheService.save (data: data, key: url.absoluteString)
          DispatchQueue.main.async {
            afronding (beeld)
          }
        } anders {
          print ("Fout bij laden afbeelding op \ (url)")
        }
      })
zelf? .task? .resume ()
    }
  })
}

Het laden van afbeeldingen gemakkelijker maken voor UIImageView

Laten we een extensie toevoegen aan UIImageView om de externe afbeelding van de URL in te stellen. Ik gebruik een bijbehorend object om deze ImageService te behouden en om oude aanvragen te annuleren. We maken goed gebruik van het bijbehorende object om ImageService aan UIImageView te koppelen. Het punt is om het huidige verzoek te annuleren wanneer het verzoek opnieuw wordt geactiveerd. Dit is handig wanneer de afbeeldingsweergaven worden weergegeven in een schuiflijst.

extensie UIImageView {
  func setImage (url: URL, placeholder: UIImage? = nil) {
    if imageService == nihil {
      imageService = ImageService (networkService: NetworkService (), cacheService: CacheService ())
    }
self.image = placeholder
    self.imageService? .fetch (url: url, completering: {[zwakke zelf] afbeelding in
      self? .image = afbeelding
    })
  }
private var imageService: ImageService? {
    krijg {
      retourneer objc_getAssociatedObject (self, & AssociateKey.imageService) als? ImageService
    }
    stel {
      objc_setAssociatedObject (
        zelf,
        & AssociateKey.imageService,
        nieuwe waarde,
        objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC
      )
    }
  }
}

Algemene gegevensbron voor UITableView en UICollectionView

We gebruiken UITableView en UICollectionView in bijna elke app en voeren bijna hetzelfde herhaaldelijk uit.

  • toon vernieuwingsregeling tijdens het laden
  • herlaad lijst in geval van data
  • fout weergeven in geval van storing.

Er zijn veel wrappers rond UITableView en UICollection. Elke voegt een andere abstractielaag toe, die ons meer macht geeft maar tegelijkertijd beperkingen toepast.

In deze app gebruik ik Adapter om een ​​generieke gegevensbron te krijgen, om een ​​type veilige verzameling te maken. Omdat we uiteindelijk alleen maar van het model naar de cellen moeten verwijzen.

Ik gebruik ook Upstream op basis van dit idee. Het is moeilijk om UITableView en UICollectionView te omzeilen, omdat het vaak app-specifiek is, dus een dunne wrapper zoals adapter is voldoende.

laatste klasse Adapter : NSObject,
UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
  var items: [T] = []
  var configureren: ((T, Cell) -> Nietig)?
  var select: ((T) -> Nietig)?
  var cellHoogte: CGFloat = 60
}

Controller en weergave

Ik heb Storyboard gedumpt vanwege veel beperkingen en veel problemen. In plaats daarvan gebruik ik code om weergaven te maken en beperkingen te definiëren. Het is niet zo moeilijk om te volgen. Het grootste deel van de boilerplate-code in UIViewController is voor het maken van weergaven en het configureren van de lay-out. Laten we die naar het uitzicht verplaatsen. Daar kun je hier meer over lezen.

/// Gebruikt om te scheiden tussen controller en weergave
klasse BaseController : UIViewController {
  laat root = T ()
func load vervangen () {
    view = root
  }
}
laatste klasse RecipeDetailViewController: BaseController  {}

Verantwoordelijkheden afhandelen met een kind View Controller

De View controller-container is een krachtig concept. Elke viewcontroller heeft een scheiding van zorg en kan samen worden samengesteld om geavanceerde functies te creëren. Ik heb RecipeListViewController gebruikt om de UICollectionView te beheren en een lijst met recepten te tonen.

laatste klasse RecipeListViewController: UIViewController {
  privé (set) var collectionView: UICollectionView!
  let adapter = Adapter  ()
  private let emptyView = EmptyView (tekst: "Geen recepten gevonden!")
}

Er is de HomeViewController die deze RecipeListViewController insluit

/// Toon een lijst met recepten
laatste klas HomeViewController: UIViewController {
/// Wanneer een recept wordt geselecteerd
  var select: ((Recept) -> Nietig)?
private var refreshControl = UIRefreshControl ()
  private let recipesService: RecipesService
  private let searchComponent: SearchComponent
  privé laat receptListViewController = RecipeListViewController ()
}

Samenstelling en afhankelijkheid injectie

Ik probeer componenten te bouwen en code samen te stellen wanneer ik kan. We zien dat ImageService gebruik maakt van de NetworkService en CacheService, en RecipeDetailViewController maakt gebruik van Recept en ReceptenService

Idealiter zouden objecten niet zelf afhankelijkheden moeten creëren. De afhankelijkheden moeten buiten worden gemaakt en doorgegeven vanuit root. In onze app is de root AppDelegate en AppFlowController, dus afhankelijkheden moeten hier beginnen.

App Transportbeveiliging

Sinds iOS 9 moeten alle apps App Transport Security gebruiken

App Transport Security (ATS) dwingt best practices af in de beveiligde verbindingen tussen een app en de back-end. ATS voorkomt onbedoelde openbaarmaking, biedt veilig standaardgedrag en is gemakkelijk aan te nemen; het is ook standaard ingeschakeld in iOS 9 en OS X v10.11. U moet ATS zo snel mogelijk gebruiken, ongeacht of u een nieuwe app maakt of een bestaande app bijwerkt.

In onze app worden sommige afbeeldingen verkregen via een HTTP-verbinding. We moeten het uitsluiten van de beveiligingsregel, maar alleen voor dat domein.

 NSAppTransportSecurity 

   NSExceptionDomains 
  
     food2fork.com 
    
       NSIncludesSubdomains 
      
       NSExceptionAllowsInsecureHTTPLoads 
      
    
  

Een aangepaste Scrollable-weergave

Voor het detailscherm kunnen we UITableView en UICollectionView gebruiken met verschillende celtypen. Hier moeten de weergaven statisch zijn. We kunnen stapelen met UIStackView. Voor meer flexibiliteit kunnen we UIScrollView gewoon gebruiken.

/// Verticaal layoutoverzicht met Auto Layout in UIScrollView
laatste klas ScrollableView: UIView {
  privé let scrollView = UIScrollView ()
  private let contentView = UIView ()
init negeren (frame: CGRect) {
    super.init (frame: frame)
scrollView.showsHorizontalScrollIndicator = false
    scrollView.alwaysBounceHorizontal = false
    addSubview (ScrollView)
scrollView.addSubview (contentView)
NSLayoutConstraint.activate ([
      scrollView.topAnchor.constraint (equalTo: topAnchor),
      scrollView.bottomAnchor.constraint (equalTo: bottomAnchor),
      scrollView.leftAnchor.constraint (equalTo: leftAnchor),
      scrollView.rightAnchor.constraint (equalTo: rightAnchor),
contentView.topAnchor.constraint (equalTo: scrollView.topAnchor),
      contentView.bottomAnchor.constraint (equalTo: scrollView.bottomAnchor),
      contentView.leftAnchor.constraint (equalTo: leftAnchor),
      contentView.rightAnchor.constraint (equalTo: rightAnchor)
    ])
  }
}

We spelden de UIScrollView aan de randen vast. We spelden het contentView-linker- en rechteranker vast aan zichzelf, terwijl het contentView-top- en bodemanker vastzetten aan UIScrollView.

De weergaven in contentView hebben beperkingen van boven en onder, dus wanneer ze worden uitgebreid, breiden ze ook contentView uit. UIScrollView maakt gebruik van Auto Layout-informatie uit deze contentView om de contentSize te bepalen. Hier is hoe ScrollableView wordt gebruikt in RecipeDetailView.

scrollableView.setup (paren: [
  ScrollableView.Pair (weergave: imageView, inzet: UIEdgeInsets (boven: 8, links: 0, onder: 0, rechts: 0)),
  ScrollableView.Pair (weergave: ingrediëntHeaderView, inzet: UIEdgeInsets (boven: 8, links: 0, onder: 0, rechts: 0)),
  ScrollableView.Pair (weergave: ingrediëntLabel, inzet: UIEdgeInsets (boven: 4, links: 8, onder: 0, rechts: 0)),
  ScrollableView.Pair (weergave: infoHeaderView, inzet: UIEdgeInsets (boven: 4, links: 0, onder: 0, rechts: 0)),
  ScrollableView.Pair (weergave: instructionButton, inzet: UIEdgeInsets (boven: 8, links: 20, onder: 0, rechts: 20)),
  ScrollableView.Pair (weergave: origineel, knop, inzet: UIEdgeInsets (boven: 8, links: 20, onder: 0, rechts: 20)),
  ScrollableView.Pair (weergave: infoView, inzet: UIEdgeInsets (boven: 16, links: 0, onder: 20, rechts: 0))
])

Zoekfunctionaliteit toevoegen

Vanaf iOS 8 kunnen we de UISearchController gebruiken om een ​​standaard zoekervaring te krijgen met de zoekbalk en de resultatencontroller. We zullen zoekfunctionaliteit inkapselen in SearchComponent zodat deze kan worden ingeplugd.

laatste klas SearchComponent: NSObject, UISearchResultsUpdating, UISearchBarDelegate {
  let receptenService: ReceptenService
  laat searchController: UISearchController
  laat receptListViewController = RecipeListViewController ()
}

Vanaf iOS 11 is er een eigenschap met de naam searchController op de UINavigationItem die het gemakkelijk maakt om de zoekbalk op de navigatiebalk weer te geven.

func add (to viewController: UIViewController) {
  indien # beschikbaar (iOS 11, *) {
    viewController.navigationItem.searchController = searchController
    viewController.navigationItem.hidesSearchBarWhenScrolling = false
  } anders {
    viewController.navigationItem.titleView = searchController.searchBar
  }
viewController.definesPresentationContext = true
}

In deze app moeten we huiden voor nu uitschakelen. NavigatieBarDuringPresentatie, want het is behoorlijk buggy. Hopelijk wordt het opgelost in toekomstige iOS-updates.

De context van de presentatie begrijpen

Inzicht in de presentatiecontext is cruciaal voor de weergave van de weergavecontroller. Bij het zoeken gebruiken we de searchResultsController.

self.searchController = UISearchController (searchResultsController: receptListViewController)

We moeten definesPresentationContext gebruiken op de bronweergavecontroller (de weergavecontroller waaraan we de zoekbalk toevoegen). Zonder dit krijgen we de searchResultsController op volledig scherm te zien !!!

Wanneer u de stijl currentContext of overCurrentContext gebruikt om een ​​viewcontroller te presenteren, bepaalt deze eigenschap welke bestaande viewcontroller in uw viewcontrollerhiërarchie daadwerkelijk wordt gedekt door de nieuwe inhoud. Wanneer een contextgebaseerde presentatie plaatsvindt, begint UIKit bij de presenterende viewcontroller en loopt de viewcontrollerhiërarchie omhoog. Als het een viewcontroller vindt waarvan de waarde voor deze eigenschap waar is, vraagt ​​het die viewcontroller om de nieuwe viewcontroller te presenteren. Als geen weergavecontroller de presentatiecontext definieert, vraagt ​​UIKit de rootviewcontroller van het venster om de presentatie af te handelen.
De standaardwaarde voor deze eigenschap is false. Sommige door het systeem geleverde weergavecontrollers, zoals UINavigationController, wijzigen de standaardwaarde in true.

Zoekacties debounderen

We moeten geen zoekopdrachten uitvoeren voor elke toetsaanslag die de gebruiker in de zoekbalk typt. Daarom is een vorm van beperking nodig. We kunnen DispatchWorkItem gebruiken om de actie in te kapselen en naar de wachtrij te sturen. Later kunnen we het annuleren.

laatste klas Debouncer {
  private let delay: TimeInterval
  private var workItem: DispatchWorkItem?
init (vertraging: TimeInterval) {
    self.delay = vertraging
  }
/// Activeer de actie na enige vertraging
  func-schema (actie: @escaping () -> Nietig) {
    werkitem? .cancel ()
    workItem = DispatchWorkItem (block: action)
    DispatchQueue.main.asyncAfter (deadline: .now () + vertraging, uitvoeren: workItem!)
  }
}

Debouncing testen met omgekeerde verwachting

Om Debouncer te testen, kunnen we de XCTest-verwachting in omgekeerde modus gebruiken. Lees er meer over in Asynchrone Swift-code testen van eenheden.

Om te controleren of een situatie niet optreedt tijdens het testen, maakt u een verwachting waaraan wordt voldaan wanneer de onverwachte situatie zich voordoet en stelt u de eigenschap isInverted in op true. Uw test zal onmiddellijk mislukken als de omgekeerde verwachting is vervuld.
class DebouncerTests: XCTestCase {
  func testDebouncing () {
    let cancelExpectation = self.expectation (beschrijving: "cancel")
    cancelExpectation.isInverted = true
let completeExpectation = self.expectation (beschrijving: "complete")
    let debouncer = Debouncer (vertraging: 0.3)
debouncer.schedule {
      cancelExpectation.fulfill ()
    }
debouncer.schedule {
      completeExpectation.fulfill ()
    }
wacht (voor: [annuleer Verwachting, voltooi Verwachting], time-out: 1)
  }
}

Gebruikersinterface testen met UITests

Soms kan kleine refactoring een groot effect hebben. Een uitgeschakelde knop kan achteraf leiden tot onbruikbare schermen. UITest helpt de integriteit en functionele aspecten van de app te waarborgen. Test moet declaratief zijn. We kunnen het robotpatroon gebruiken.

klasse receptenUITests: XCTestCase {
  var-app: XCUIA-toepassing!
  func setUp () vervangen
    super.setUp ()
    continueAfterFailure = false
    app = XCUIApplication ()
  }
  func testScrolling () {
    app.launch ()
    let collectionView = app.collectionViews.element (boundBy: 0)
    collectionView.swipeUp ()
    collectionView.swipeUp ()
  }
  func testGoToDetail () {
    app.launch ()
    let collectionView = app.collectionViews.element (boundBy: 0)
    let firstCell = collectionView.cells.element (boundBy: 0)
    firstCell.tap ()
  }
}

Hier zijn enkele van mijn artikelen over testen.

  • UITests uitvoeren met Facebook-login in iOS
  • Snel testen met gegeven wanneer gegeven patroon

Hoofddraadbeschermer

Toegang tot de gebruikersinterface vanuit de achtergrondwachtrij kan tot potentiële problemen leiden. Eerder moest ik MainThreadGuard gebruiken, nu Xcode 9 Main Thread Checker heeft, heb ik dat net ingeschakeld in Xcode.

De hoofdthreadchecker is een op zichzelf staand hulpmiddel voor Swift- en C-talen die ongeldig gebruik van AppKit, UIKit en andere API's op een achtergrondthread detecteert. Het bijwerken van de gebruikersinterface op een andere thread dan de hoofdthread is een veel voorkomende fout die kan leiden tot gemiste UI-updates, visuele defecten, gegevensbeschadigingen en crashes.

Prestaties en problemen meten

We kunnen Instrumenten gebruiken om de app grondig te profileren. Voor snelle metingen kunnen we naar het tabblad Debug Navigator gaan en het CPU-, geheugen- en netwerkgebruik bekijken. Bekijk dit coole artikel voor meer informatie over instrumenten.

Prototyping met Speeltuin

Playground is de aanbevolen manier om een ​​prototype te maken en apps te bouwen. Op WWDC 2018 introduceerde Apple Create ML die Playground ondersteunt om het model te trainen. Bekijk dit coole artikel voor meer informatie over ontwikkeling door speelplaatsen in Swift.

Waar te gaan vanaf hier

Bedankt dat je zo ver bent gekomen. Ik hoop dat je iets nuttigs hebt geleerd. De beste manier om iets te leren, is om het gewoon te doen. Als u dezelfde code telkens opnieuw schrijft, maakt u deze als een component. Als een probleem het je moeilijk maakt, schrijf er dan over. Deel uw ervaring met de wereld, u zult veel leren.

Ik raad je aan het artikel Beste plaatsen om iOS-ontwikkeling te leren lezen voor meer informatie over iOS-ontwikkeling.

Als u vragen, opmerkingen of feedback heeft, vergeet deze dan niet toe te voegen aan de opmerkingen. En als u dit artikel nuttig vond, vergeet dan niet te klappen.

Als je dit bericht leuk vindt, overweeg dan om mijn andere artikelen en apps te bezoeken