App-Entwicklung mit NativeScript

von | 18.04.2020 | Softwareentwicklung | 0 Kommentare

Anfang des Jahres habe ich hier im Blog einen Beitrag über die native Cross-Plattform App-Entwicklung mit Flutter geschrieben.¹ Nun ergab sich im Zuge meines dualen Studiums an der HWR Berlin² die Möglichkeit, gemeinsam mit einem Kommilitonen ein weiteres App-Entwicklungsframework auszuprobieren: NativeScript.

NativeScript ist ein OpenSource-Framework, das von Telerik entwickelt wird. Es verspricht die Entwicklung von vollkommen nativen Anwendungen basierend auf Webtechnologien (HTML, CSS und Javascript). Populäre Webframeworks wie Angular und Vue werden durch NativeScript ebenfalls unterstützt. Für meinen Kommilitonen und mich kam das gelegen, da wir beide eine gewisse Erfahrung im Umgang mit Angular haben, und diese Erfahrung bei der Entwicklung nutzen wollten. Ganz so einfach sollte es am Ende doch nicht sein. Ohne etwas vorwegzunehmen, NativeScript kann eine Angularanwendung nicht 1:1 in eine mobile Smartphone-App transpilieren. Die Unterschiede und Gemeinsamkeiten der Frameworks möchte ich im folgenden Beitrag beschreiben.

Das Projekt-Setting und das initiale Setup

Ich möchte mit Ihnen eine kleine Anwendung bauen, die über die API der OpenLigaDB³ zehn zufällige Fussballspiele lädt und diese in einer Liste anzeigt. Beim Klicken auf ein Spiel soll eine neue Seite erscheinen, welche Details zu diesem Spiel anzeigt. Dazu nutze ich NativeScript mit der Angular-Integration. Ein Grundverständnis im Umgang mit Angular ist sicherlich ein Vorteil. Außerdem gehe ich davon aus, dass Sie NativeScript bereits installiert haben. Falls nicht, der Installationsvorgang ist sehr einfach und unter https://docs.nativescript.org/angular/start/quick-setup gut beschrieben.

Initiales Setup:

Initialisieren Sie mit

tns create angular-soccer-app -–ng

das Projekt.

Wechseln Sie mit

cd angular-soccer-app

in den App-Folder und testen Sie bitte mit dem Kommando

tns preview

den Erfolg des Installationsvorgangs.

Um die App auf einem Gerät ausführen zu können, werden zwei Apps benötigt:

  • NativeScript PlayGround
  • NativeScript Preview.

Mit der PlayGround-App scannen Sie den QR-Code, der durch den „tns preview“-Command erzeugt wird. Nach dem Scan öffnet sich die Preview-App und Sie können die Anwendung testen.

NativeScript bietet mit einem sogenannten Cloud-Build eine sehr einfache Möglichkeit, Anwendungen schnell zu testen, ohne, wie bspw. in Flutter, das gesamte Android SDK und einen Emulator vorher aufsetzen zu müssen.

Das Resultat sollte auf dem Gerät etwa so aussehen:

App-Entwicklung mit NativeScript - der Beginn

Bei der Initialisierung erstellt NativeScript eine Beispielapplikation, die Sie aktuell sehen. Ich möchte aber komplett von vorne beginnen, löschen Sie deshalb bitte den item-Ordner (angular-soccer-app/src/app/item) und passen das app-routing.module und das app-module wie folgt an:

app-routing.module.ts

import { NgModule } from "@angular/core";
import { NativeScriptRouterModule } from "nativescript-angular/router";
import { Routes } from "@angular/router";

const routes: Routes = [
];

@NgModule({
    imports: [NativeScriptRouterModule.forRoot(routes)],
    exports: [NativeScriptRouterModule]
})
export class AppRoutingModule { }

app.module.ts

// WICHTIG
import { NativeScriptHttpClientModule } from "nativescript-angular/http-client";

import { NgModule, NO_ERRORS_SCHEMA } from "@angular/core";
import { NativeScriptModule } from "nativescript-angular/nativescript.module";

import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";

@NgModule({
    bootstrap: [
        AppComponent
    ],
    imports: [
        // WICHTIG
        NativeScriptHttpClientModule,
        NativeScriptModule,
        AppRoutingModule
    ],
    declarations: [
        AppComponent
    ],
    providers: [],
    schemas: [
        NO_ERRORS_SCHEMA
    ]
})
/*
Pass your application module to the bootstrapModule function located in main.ts to start your app
*/
export class AppModule { }

Hinweis: Um den Angular http-Client in NativeScript nutzen zu können, müssen Sie zuerst das NativeScriptHttpClientModule importieren. Interessanterweise liefert die entsprechende NativeScript-Dokumentation keinen entsprechenden Hinweis, aber aus eigener Erfahrung weiß ich, dass es ohne den Import nicht funktioniert.

Implementierung der Listenkomponente

Ein Vorteil von NativeScript ist, dass jedes Projekt, das über das NativeScript CLI erstellt wurde, auch mit den Befehlen der Angular CLI arbeiten kann.

Das bedeutet für die Erstellung der Listenkomponente können Sie wie gewohnt

ng generate component games-list

ausführen, so dass ein neuer Ordner mit der Komponente erstellt wird. Im app-routing.module fügen Sie nun die Komponente hinzu:

app-routing.module.ts

import { NgModule } from "@angular/core";
import { NativeScriptRouterModule } from "nativescript-angular/router";
import { Routes } from "@angular/router";
import { GamesListComponent } from "./games-list/games-list.component";

const routes: Routes = [
    {path: 'games-list', component: GamesListComponent},
    {path: '', redirectTo: '/games-list', pathMatch: 'full'},
    {path: '**', component: GamesListComponent }

];

@NgModule({
    imports: [NativeScriptRouterModule.forRoot(routes)],
    exports: [NativeScriptRouterModule]
})
export class AppRoutingModule { }

Nun geht es zum Laden der Daten von der OpenLigaDB, um diese anschließend als Liste anzuzeigen. Erstellen Sie dazu den GameService mit dem Befehl

ng generate service game

Initialisieren Sie ihn wie folgt:

game.service.ts

import { Injectable } from '@angular/core';
import { Observable, from } from 'rxjs';
import { map, toArray, mergeMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { Game } from './games-list/game.type';

@Injectable({
  providedIn: 'root'
})
export class GameService {

  constructor(private http: HttpClient) { }

  public getGames(): Observable<Game[]> {
    const randomGameIds = this.getRandomNumbers();
    return from(randomGameIds).pipe(
      mergeMap((gameId) => this.http.get<any>(`https://www.openligadb.de/api/getmatchdata/${gameId}`).pipe(
        map((val) => {
          return { homeTeam: val.Team1.TeamName, awayTeam: val.Team2.TeamName, homeTeamScore: val.MatchResults.length > 0 ? val.MatchResults[0].PointsTeam1 : undefined, awayTeamScore: val.MatchResults.length > 0 ? val.MatchResults[0].PointsTeam2 : undefined, date: new Date(val.MatchDateTimeUTC), league: val.LeagueName, matchday: val.Group.GroupName }
        })
      ))
    ).pipe(toArray());
  }

  private getRandomNumbers(): number[] {
    const numbers = [];
    for (let i = 0; i < 10; i++) {
            numbers.push(Math.floor(Math.random() * (100 - 1 + 10) + 10));
    }
    return numbers
  }
}

Jetzt können Sie die games-list-Komponente aufrufen und alle Spiele laden:

games-list.component.ts

import { Component, OnInit } from '@angular/core';
import { GameService } from '../game.service';
import { Game } from './game.type';

@Component({
  selector: 'ns-games-list',
  templateUrl: './games-list.component.html',
  styleUrls: ['./games-list.component.css'],
  moduleId: module.id,
})
export class GamesListComponent implements OnInit {

  public games: Game[] = [];

  constructor(private gameService: GameService) { }

  ngOnInit() {
    this.gameService.getGames().subscribe(
      (games) => this.games = games
    )
  }

}

Nun gilt es, die Daten in die Komponente zu laden und in der UI anzuzeigen. In Angular würden Sie per ngFor–Direktive über die games-list-Komponente iterieren. Diese Direktive könnten Sie auch in NativeScript verwenden, allerdings gestaltet sich die Verwendung von ListView in mobilen Anwendungen einfacher. Eine Liste, die alle unsere Spiele anzeigt, lässt sich wie folgt implementieren:

games-list.component.html

<ListView class="list-group" [items]="games" (itemTap)="onItemTap($event)">
    <ng-template let-game="item">
        <GridLayout columns="*,  *, *" padding="15">
            <Label textAlignment="center" col="0" [text]="game.homeTeam"></Label>
            <Label textAlignment="center" col="1" text="gegen"></Label>
            <Label textAlignment="center" col="2" [text]="game.awayTeam"></Label>
        </GridLayout>
    </ng-template>
</ListView>

Sie können leicht erkennen, dass NativeScript nicht mit der Standard-HTML-Syntax sondern mit einem eigenen XML-Schema zum Styling der UI arbeitet. Dies sorgt dafür, dass sich lediglich die von NativeScript definierten Komponenten benutzen lassen. <div> und <h1> – Tags können also nicht zum Stylen der UI benutzt werden. Diese Beschränkung sorgt dafür, dass Sie Ihr User Interface nicht komplett individuell gestalten können.

Implementierung der Listendetails

Was tun Sie nun, um per Klick auf einem bestimmten Item auf eine neue Seite zu wechseln, so dass Sie die Details zum entsprechenden Spiel erhalten? Für die Navigation innerhalb der App können Sie in NativeScript die sogenannte RouterExtension nutzen. Diese ist sehr ähnlich zum Angular-Routing, allerdings optimiert für die mobile Navigation, so dass beispielsweise beim Wechsel auf eine neue Seite eine „typische“ Übergangsanimation zu sehen ist. Registrieren Sie nun also einen EventListener auf das ItemTap-Event des ListView:

games-list.component.html

<ListView class="list-group" [items]="games" (itemTap)="onItemTab($event)">
    …
</ListView>

Navigieren Sie nun zur Games-List-Component auf game-details, um dort die ID des selektierten Spiels zu übergeben:

games-list.component.ts

import { RouterExtensions } from 'nativescript-angular/router';

…
export class GamesListComponent implements OnInit {
…

  constructor(private gameService: GameService, private router: RouterExtensions) { }
	…
  public onItemTab(element: any): void {
    const tabbedItemIndex = element.index;
    this.router.navigate(['/game-detail'], { queryParams: { id: this.games[tabbedItemIndex].id } });
  }

}

Natürlich muss in der Routing-Komponente die game-detail-Route hinzugefügt werden:

app-routing.ts

import { NgModule } from "@angular/core";
import { NativeScriptRouterModule } from "nativescript-angular/router";
import { Routes } from "@angular/router";
import { GamesListComponent } from "./games-list/games-list.component";
import { GameDetailComponent } from "./game-detail/game-detail.component";

const routes: Routes = [
    {path: 'games-list', component: GamesListComponent},
    {path: "game-detail", component: GameDetailComponent}
    {path: '', redirectTo: '/games-list', pathMatch: 'full'},
    {path: '**', component: GamesListComponent }
];

@NgModule({
    imports: [NativeScriptRouterModule.forRoot(routes)],
    exports: [NativeScriptRouterModule]
})
export class AppRoutingModule { }

Nachdem Sie nun mit 

ng generate component game-detail

die zugehörige Komponente erstellt haben, können Sie die App ausführen. Sie sollten beim Klicken auf ein Element auf der nächsten Seite einen Button sehen, der signalisiert, dass die Game-Detail-Komponente existiert.

Die Implementierung der Game-Detail-Komponente gelingt jetzt relativ leicht. Über den ActivatedRoute-Service erhalten Sie die übergebene ID, um sich anschließend im GameService die Spieldetails zu dieser ID zu holen:

game-detail.component.ts

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { GameService } from '../game.service';
import { Game } from '../games-list/game.type';

@Component({
  selector: 'ns-game-detail',
  templateUrl: './game-detail.component.html',
  styleUrls: ['./game-detail.component.css'],
  moduleId: module.id,
})
export class GameDetailComponent implements OnInit {

  public game: Game

  constructor(private route: ActivatedRoute, private gameService: GameService) { }

  ngOnInit() {
    const gameId: number = this.route.snapshot.queryParams['id'];
    this.gameService.getGameById(gameId).subscribe(
      (game) => this.game = game
    )
  }

}

game.service.ts

export class GameService {

  constructor(private http: HttpClient) { }

…

  public getGameById(gameId: number): Observable<Game> {
    return this.http.get<any>(`https://www.openligadb.de/api/getmatchdata/${gameId}`).pipe(
      map((val) => {
        return {
          id: val.MatchID,
          homeTeam: val.Team1.TeamName,
          awayTeam: val.Team2.TeamName,
          homeTeamScore: val.MatchResults.length > 0 ? val.MatchResults[0].PointsTeam1 : undefined,
          awayTeamScore: val.MatchResults.length > 0 ? val.MatchResults[0].PointsTeam2 : undefined,
          date: new Date(val.MatchDateTimeUTC), league: val.LeagueName,
          matchday: val.Group.GroupName
        }
      })
    );
  }
}

Und jetzt zeigen Sie die Infos noch entsprechend an: 

game-detail.component.html

<StackLayout *ngIf="game != undefined" class="text-padding">
    <Label text="Am {{game.date | date}} spielten in der Liga {{game.league}} am {{game.matchday}} {{game.homeTeam}} gegen {{game.awayTeam}}" textWrap=true></Label>
    <Label text="Das Endergebnis lautet {{game.homeTeamScore}} : {{game.awayTeamScore}}"></Label>
</StackLayout>

Bemerkenswert ist, dass die Angular-Direktiven ngIf oder auch ngFor durchaus in den NativeScript-Templates genutzt werden können. Auch Styling mithilfe von CSS ist möglich. Jedoch sollten Sie beachten, dass NativeScript nur eine Teilmenge aller CSS-Propertys unterstützt. Das CSS-Property Box-Shadow kann bspw. nicht angewendet werden. Eine vollständige Liste aller anwendbaren CSS-Styles finden Sie unter https://docs.nativescript.org/ui/styling#nativescript-specific-css-properties

Wollen Sie vielleicht noch einen generellen Innenabstand der beiden Label von 50 Pixel setzen?

game-detail.component.css

.text-padding {
    padding: 50px;
}

Damit ist Ihre Anwendung fertig. Und so sollte sie nun funktionieren:

NativeScript - das Ergebnis

Fazit

Gerade wenn man aus dem Webumfeld kommt und bereits Erfahrung mit Angular (oder anderen Webtechnologien) hat, bietet NativeScript relativ große Fortschritte bei geringem Zeitaufwand. Der Einstieg und die ersten Schritte fielen mir daher deutlich einfacher als bspw. in Flutter. Allerdings bekamen wir mit zunehmender Größe unseres Projektes auch die Limitierungen des Frameworks zu spüren. Bspw. ist NativeScript in Teilen beim Styling und der individuellen Modifikation der Komponenten eingeschränkt.

Um Zusatzfunktionalitäten und mobile-spezifische Features (z.B. die Kamera) nutzen zu können, bietet NativeScript ein Plugin-System, mit dem diese integriert werden können. Auch bei den Plugins ist uns einige Male aufgefallen, dass die Standardfunktionen zwar funktionieren, es aber zu Problemen kommen kann, wenn individuellere Lösungen benötigt werden. Bspw. wollten wir in der Anwendung Bilder zu einem Server schicken, das Senden von Files wird allerdings (noch) nicht von NativeScript unterstützt. Deshalb haben wir das Plugin nativescript-background-http genutzt, das eine entsprechende Funktionalität anbietet. Mit Hilfe des Plugins konnten wir die Bilder hochladen. Als wir in der Folge eine Authentifizierung mittels JWT-Token implementieren wollten, stellte sich heraus, dass nativescript-background-http keine Unterstützung für den Angular-Interceptor bietet und wir mussten uns erneut einen Workaround für die Umsetzung überlegen.

Mein Tipp lautet daher: Bevor Sie sich für NativeScript entscheiden, sollten Sie sorgfältig evaluieren, wie individuell Ihre Anwendung ist. Für eine einfache Applikation, die Daten lädt, aufbereitet und anzeigt, ist NativeScript eine sehr empfehlenswerte Alternative. Wenn allerdings Designvorstellungen realisiert werden sollen oder individuelle Lösungen gefragt sind, würde ich empfehlen, einen Blick auf Flutter zu werfen.

 

Hinweise:

[1] Mein Beitrag: Smartphone-Anwendungen mit Flutter erstellen
[2] Zusammen mit der Hochschule für Wirtschaft und Recht Berlin bietet t2informatik auch in diesem Jahr einen Ausbildungsplatz im Rahmen eines dreijährigen Dualen Studiums in der Fachrichtung Informatik an. Hier geht’s zu den Details.
[3] API der OpenLigaDB: https://www.openligadb.de/

Mark Heimer
Mark Heimer
Mark Heimer ist dualer Student bei t2informatik in Kooperation mit der Hochschule für Wirtschaft und Recht Berlin, Fachrichtung Informatik. Hackathons und die Programmierung von Computerspielen sind seine Leidenschaft. In seinen Praxisphasen entwickelt er derzeit eine t2informatik-interne Software für Zeiterfassung.