placeholder

Onze opzet voor het automatisch bouwen en uitrollen van mobiele apps (CI/CD)

Lees hoe je een systeem maakt dat elke samenvoeging van mobiele apps controleert, bouwt, test en uitvoert.

Wanneer we nieuwe functies ontwikkelen of problemen oplossen voor onze applicaties, willen we deze samen met wijzigingen van andere ontwikkelaars integreren en zo snel mogelijk testen. We willen er ook zeker van zijn dat deze wijzigingen voldoen aan specificaties en standaarden, bekende beveiligingsproblemen vermijden en voorkomen dat ze onverwachte resultaten opleveren, zoals het (onbedoeld) veranderen van bestaande functionaliteit. Als ontwikkelaars houden we van automatisering en daarom hebben we voor de meeste websites en webapplicaties van onze klanteneen systeem van Continuous Integration en Continuous Delivery/ContinuousDeployment opgezet. Afgestemd op verschillende soorten projecten hebben we nu verschillende geautomatiseerde tests die bepaalde soorten functionaliteit testen, onze code analyseren, controleren op standaarden, enzovoort.

Voor apps testten en implementeerden we echter nog steeds handmatig. Met een toenemend aantal apps en een toenemende complexiteit was het tijd om hier verandering in te brengen.

Onze situatie

Hoewel de ontwikkeling van mobiele apps slechts ongeveer 10% van onze ontwikkeling uitmaakt, ontwikkelen en onderhouden we verschillende apps. Sommige klein (10-100 maandelijkse gebruikers), sommige groot (10.000+ maandelijkse gebruikers), maar in alle gevallen verwachten onze klanten een stabiele app zonder regressie na elke update. Sommige van deze apps zijn native apps, de meeste zijn ontwikkeld met React Native.

Als digitaal bureau hebben we een aantal ontwikkelaars die tegelijkertijd aan meerdere projecten voor verschillende klanten werken. Over het algemeen testen ze hun werk op meerdere apparaten en meestal is het dan een andere persoon die de daadwerkelijke functionaliteit test en beoordeelt voordat het aan de klant wordt geleverd. De meeste van onze klanten voeren zelf tests uit als onderdeel van hun acceptatieprocedure. Na acceptatie wordt de app bijgewerkt en naar de eindgebruikers geduwd.

Bijna al onze apps die we ontwikkelen hebben een API-verbinding. Onze klanten hebben een vorm van scheiding van systemen voor testen/acceptatie en productie, meestal volgens de DTAP-standaarden. Strikte scheiding van gegevens is natuurlijk erg belangrijk: gegevens van een acceptatieomgeving mogen nooit gegevens op een live-omgeving beïnvloeden. En voordat we nieuwe functionaliteit uitrollen naar productie (d.w.z. live gebruikers) willen we deze kunnen testen en accepteren.

Bestaande systemen

Bouwgereedschappen

Zowel Xcode als Android Studio hebben ingebouwde commandoregeltools om apps te bouwen. Xcode heeft 'xcodebuild', Android Studio heeft 'gradle'. Xcode heeft ook een tool genaamd 'altool' voor het uploaden van apps naar de store, Android Studio heeft zo'n tool niet en biedt alleen een API.

Fastlane

Fastlane is een kant-en-klaar systeem om Android- en iOS-apps te bouwen en te testen. Het werkt door te lezen uit een specifiek 'Fastlane'-bestand dat informatie bevat over hoe de app gebouwd moet worden.

Het probleem waar we echter tegenaan liepen met Fastlane is dat er geen eenvoudige ondersteuning is voor 'build flavours' met verschillende applicatie identifiers. Dit betekent bijvoorbeeld dat er geen eenvoudige manier is om Alpha, Beta en Production apps van elkaar te scheiden om wijzigingen goed te kunnen testen.

Xcode cloud (bèta)

Apple kwam onlangs met een oplossing voor het bouwen van apps in de cloud, met een focus op automatisering voor CI/CD-systemen: Xcode cloud. Hoewel we ons hebben aangemeld voor early access, was het helaas nog niet beschikbaar voor ons toen we onze oplossing aan het maken waren. Misschien kan het in de toekomst wel een aantal van onze wensen vervullen, aan de Apple-kant van de dingen.

Onze opzet

Na uitgebreid onderzoek en testen kwamen we met een uitbreiding van ons Gitlab CI/CD-systeem. Nu start elke codewijziging (merge) een pijplijn die onze apps controleert, bouwt, test en uitrolt naar de App Store / Play Store accounts van onze klanten. Hier maken ze gebruik van Apple Testflight en Google Play 'testtracks' waarin verschillende versies van de app tegelijkertijd kunnen staan.

Laten we onze oplossing in detail bespreken.

Aparte apps voor aparte omgevingen (Alpha, Beta en Live)

Ten eerste moeten we kunnen testen volgens het DTAP-principe. We doen onze ontwikkeling lokaal, met behulp van de build-systemen van zowel Xcode als Android Studio, en Docker voor het uitvoeren van de API-kant van de dingen.

Voor testen, acceptatie en productie moet onze app met aparte omgevingen praten. Zowel de App Store als de Play Store maken gebruik van de bovengenoemde testtrajecten. Dit maakt het mogelijk om updates van de app vrij te geven aan een bepaalde testgroep voordat deze wordt vrijgegeven aan daadwerkelijke live gebruikers. De app wordt over het algemeen één keer gebouwd en vervolgens 'gepromoveerd' naar het volgende spoor, om uiteindelijk als productieversie uit te komen. Aangezien de omgevingen 'gebakken' zijn in de build, kunnen we niet simpelweg een app maken die verbinding maakt met één omgeving en die vervolgens naar Live pushen. We zien geen andere manier dan drie aparte versies van de app te bouwen die we Alpha (testen), Beta (acceptatie) en Live (productie) noemen.

Git integratie

Om onze workflow enigszins beheersbaar te houden, maken we gebruik van de Gitflow methode. Dit betekent dat we gebruik maken van het git branching en tagging systeem om versies van een applicatie te scheiden: feature branches voor (lokale) ontwikkeling, develop branch voor testen, master branch voor acceptatie, en tagging voor productiereleases.

Omgekeerd gebruiken we dezelfde branching/tagging methode voor onze app releases: develop voegt een Alpha versie voor de app samen, master voegt een Beta versie samen, en tagging geeft een Live app vrij aan productie.

Versiebeheer

Elke verandering in git (push of merge naar bepaalde branches) triggert een nieuwe pijplijn, elke pijplijn triggert een nieuwe build en resulteert in een nieuwe release. De release wordt geversioneerd met het pijplijnnummer. Schakelen tussen build versies is erg gemakkelijk, dus het detecteren van een bug die is geïntroduceerd of het lokaliseren van een specifieke verandering in code is net zo gemakkelijk.

Testgroepen

Door gebruik te maken van de testtracks in de stores kunnen we aparte groepen gebruikers maken om aparte app-versies te testen. Bij Unc Inc bijvoorbeeld laten we onze ontwikkelaars meestal het 'interne spoor' testen, wat betekent dat ze een bijgewerkte versie van elke build krijgen. We hebben dan een aparte interne groep die de app test nadat de eerste tests zijn voltooid. Onze klanten testen meestal de bètaversie van de app, in sommige gevallen ook met aparte groepen voor intern en extern testen. Deze opzet is echter afhankelijk van de wensen van onze klant en het type app, maar is erg flexibel en kan op veel verschillende manieren worden gebruikt.

Xcode projectinstelling

Xcode ondersteunt afzonderlijke configuratiesets per project. Standaard wordt dit gebruikt om onderscheid te maken tussen een debug en een release build, maar we kunnen ook onze eigen instellingen toevoegen. Elke projectinstelling kan op zijn beurt worden gedifferentieerd per configuratie. We kunnen bijvoorbeeld een app Bundle ID instellen per configuratie, wat betekent dat we een compleet andere app kunnen bouwen door van configuratie te wisselen met de `-configuration` vlag van `xcodebuild`.

Android Gradle-plugin

Android Studio ondersteunt ook afzonderlijke configuraties met behulp van Build Variants.

Om het gemakkelijker te maken om vanuit CI te bouwen, hebben we een Gradle-bibliotheek als hulptool gemaakt. Deze bibliotheek maakt het mogelijk om te bouwen met versienummers, applicatie-identifier en ondertekening van sleutels vanaf de opdrachtregel als Gradle-eigenschappen. Door dit te gebruiken hoeven we tijdens de CI-pijplijn geen waarden te vervangen in het bestand build.gradle. Dit stelt ons ook in staat om de sleutels op te slaan op een veilige locatie die toegankelijk is voor CI. De repository met de bibliotheek is openbaar en kan gevonden worden op: https://github.com/uncinc/android-ci

Gitlab CI/CD

Alle projecten van onze klanten worden gehost op onze zelf gehoste Gitlab instance. Gezien het zeer goede CI/CD systeem, gebruiken we dit voor de meeste van onze integraties en implementaties, dus om te integreren met onze workflow, moesten we het ook laten werken voor app implementaties.

Onze pijplijn ziet er ongeveer zo uit:

  1. Begin met linting, codecontroles, enz.
  2. Bouw de juiste app (Alpha, Beta, Live)
  3. Test de app
  4. Verifieer en upload de app

iOS bouwstap:

build_ios_project:
  stadium: bouwen
  afhankelijkheden:
    - yarn
  tags:
    - ios
  script:
    - ln -s ~/app-envs/$IOS_SCHEME/.env .env
    - ln -s ~/app-envs/$IOS_SCHEME/GoogleService-Info.plist ios/GoogleService-Info.plist
    - yarn pre-build uitvoeren
    - cd ios
    - agvtool nieuwe-versie -alle "$CI_COMMIT_TAG"
    - mkdir -p build
    - arch -x86_64 pod installeren
    - xcodebuild -workspace $IOS_XCODE_WS -scheme $IOS_SCHEME -sdk iphoneos -configuration Release clean | xcpretty
    - xcodebuild -workspace $IOS_XCODE_WS -scheme $IOS_SCHEME -sdk iphoneos -configuration Release -allowProvisioningUpdates archive -archivePath "./build/$IOS_SCHEME.xcarchive" | xcpretty | tail -n 200
    - xcodebuild -exportArchive -archivePath "./build/$IOS_SCHEME.xcarchive" -exportOptionsPlist exportOptions.plist -exportPath "./build" -allowProvisioningUpdates | xcpretty | tail -n 200
  artefacten:
    paden:
      - ios/build/$IOS_SCHEME.ipa
      - ios/build/*/dSYMs/*.app.dSYM
    verlopen_in: 1 dag
  alleen:
    refs:
      - tags
    variabelen:
      - $IOS_XCODE_WS != null

Android bouwstap:

build_android_project:
  stadium: bouwen
  afhankelijkheden:
    - yarn
  tags:
    - apps
  script:
    - ln -s ~/app-envs/$IOS_SCHEME/.env .env
    - ln -s ~/app-envs/$IOS_SCHEME/google-services.json android/app/google-services.json
    - yarn pre-build uitvoeren
    - cd android
    - ./gradlew bundleProd -Pandroidci.signingProperties=$ANDROID_SIGNING_PROPERTIES -Pandroidci.versionCode=$CI_PIPELINE_ID
  artefacten:
    paden:
      - android/app/build/generated/sourcemaps/react/prod/release/index.android.bundle.map
      - android/app/build/outputs/bundle/prodRelease/app-prod-release.aab
    verlopen_in: 1 dag
  alleen:
    refs:
      - tags
    variabelen:
      - $ANDROID_SIGNING_PROPERTIES != null
hero-img.jpg

Tekortkomingen

Er zijn een paar dingen die onze workflow verstoren. Enkele van de problemen die we tegenkwamen:

Xcode versie problemen

Apple wil dat ontwikkelaars snel en vaak updaten. Als je je strikt aan het Apple ecosysteem houdt, is dit over het algemeen geen probleem. Maar vooral als je React Native apps ontwikkelt, worden de projectinstellingen gegenereerd door CocoaPods, en die moeten een inhaalslag maken.

Uiteindelijk draaien we oudere versies van Xcode, die minstens een of twee kleine versies achterlopen, soms zelfs een volledige grote versie. Een vervelend nadeel hiervan is dat iOS over het algemeen automatisch wordt bijgewerkt naar de nieuwste versie (met goede reden), maar om apps te kunnen debuggen op een fysiek apparaat, heb je ook de nieuwste versie van Xcode nodig. Het advies is om automatische updates uit te schakelen op je testapparaten, of, nog beter, om meerdere apparaten te hebben met verschillende versies. Op het moment van schrijven is Xcode 13 beta net uitgekomen, Xcode 12.5 is de nieuwste en Xcode 12.3 is de versie die wij gebruiken.

Een ander nadeel hiervan is dat onze huidige CI slechts één versie van Xcode (en CocoaPods) tegelijk ondersteunt, wat betekent dat al je apps ongeveer dezelfde versie van pakketten moeten gebruiken. Het updaten van React kan een nieuwere versie van CocoaPods vereisen, dus als je één app wilt updaten, moet je ze allemaal updaten. Het draaien van meerdere versies op één systeem is mogelijk, maar een eenvoudigere oplossing is het instellen van meerdere CI runners op afzonderlijke apparaten.

CocoaPods versie problemen

Op dit moment zijn er twee CocoaPods-versies die we gebruiken: 1.9.8 en 1.10.0. De laatste versie bouwt prima, maar lijkt ongeldige binaries te maken, met de gevreesde foutmelding: `error: archive at path '*.xcarchive' is malformed`. Bouwen met 1.9.8 werkt prima, behalve dat we alle `VALID_ARCHS` configuratieopties moeten verwijderen om de volgende foutmelding te voorkomen: `Pods-appName-artifacts.sh: regel 85: ARCHS: niet-gebonden variabele`.

Apple Silicon architectuurproblemen

Om ons bouwproces te versnellen, hebben we meteen een nieuwe Mac Mini M1 besteld. De compileertijden waren inderdaad ongeveer 7 keer sneller vergeleken met onze oudere Mac Mini Intel i7. Maar met een nieuwe architectuur komen ook nieuwe problemen. We kwamen nogal wat problemen tegen met dynamic library linking, vooral als het om binaire bibliotheken ging. We kwamen verschillende 'onbekend symbool' fouten tegen.

Onze huidige oplossing is om de meeste tools in x86_64 modus te draaien, vooral wanneer we lokaal ontwikkelen. Dit betekent het uitvoeren van `arch -x86_64 pod install` en het uitvoeren van Xcode in Rosetta-modus:

Android problemen

Ik zal ontkennen dat ik dit ooit geschreven heb, maar het lijkt erop dat Android de meest ontwikkelvriendelijke omgeving is om mee te werken. Zodra we de Gradle plugin schreven om met onze CI te werken, zijn Android builds altijd de meest stabiele geweest. Er zijn wat kleine problemen met het aan de praat krijgen van de simulator op Apple Silicon platforms, maar er zijn tot nu toe geen problemen met het puur bouwen van apps.


Conclusie

Voor onze gegeven situatie waren er geen kant-en-klare oplossingen voor CI/CD-integratie voor apps beschikbaar. Het was zeker de moeite waard om tijd te investeren in het opzetten van een aangepast systeem. Wijzigingen aan apps zijn nu binnen enkele minuten beschikbaar om te testen, wat zowel onze workflow als onze kwaliteit ten goede komt. De implementatie is vrij eenvoudig en bespaart onze ontwikkelaars veel handmatig bouwen en vrijgeven, geeft onze testers veel meer overzicht over welke functies getest kunnen worden en het opsporen van problemen met wijzigingen is veel eenvoudiger met een nieuwe releaseversie per pijplijn.