返回博客首页
← 所有文章

从一个代码库构建 PWA、iOS 应用和 Android 应用

2018 年 10 月 30 日 — 作者:TJ VanToll

我一直以来都在纠结于选择 Web 还是原生开发,而且经常选择错误。我曾构建过被用于原生应用的 Web 应用,也曾浪费时间构建没有找到受众的原生应用。

在我目前担任 Progress 移动端开发者布道师的工作中,我每周都会与后悔自己 Web 或原生开发决策的开发者交谈。有时直到遇到 Web 的限制才会意识到需要一个原生应用,反之,有时则是在经历了构建多个原生应用的漫长过程后才意识到 Web 应用可以满足自己的需求。

好消息是,JavaScript 开发者不再需要做出这种艰难的选择。通过使用最近发布的 NativeScript 和 Angular 集成,现在可以非常轻松地从一个代码库构建 PWA(渐进式 Web 应用)、原生 iOS 应用和原生 Android 应用。

running

在这篇文章中,我将向您展示它是如何工作的。您将学习如何为这三个平台构建应用,以及我自己在经历这个过程中学到的一些技巧和窍门。

您正在构建什么

在过去的一个月里,我构建了一个基于宝可梦的清单应用,并将其部署到 Google Play、iOS App Store 和 Web。该应用是一个有意设计得很简单的应用,旨在帮助教授 NativeScript 和 Angular 技术栈的工作原理。

apps

在本文中,我将引导您构建一个类似的清单风格的应用,它看起来像这样。

selecting

您可以跟随本文进行操作,作为开始构建自己的代码共享应用程序的一种方式,或者只是浏览代码以了解整个过程的工作原理。

开始构建您的应用

准备好构建后,第一步是安装 Angular CLI、NativeScript CLI 和 NativeScript schematics,所有这些工具都可以在 npm 上找到。打开您的终端或命令提示符并运行以下命令。

npm install -g @angular/cli
npm install -g nativescript
npm install -g @nativescript/schematics

以下是这些工具的作用。

  • Angular CLI 是一个用于构建和运行 Angular 应用的命令行界面。
  • NativeScript 是一个开源框架,用于使用 JavaScript 或 TypeScript 构建 iOS 和 Android 应用。NativeScript CLI 是一个用于创建和运行 NativeScript 应用的命令行界面。
  • NativeScript schematics 是一个 Angular CLI 扩展,它增加了执行 NativeScript 相关操作的能力。正如您将在稍后看到的,正是它使您的 Angular 应用能够在 iOS 和 Android 上运行。

安装完成后,下一步是创建一个应用。为此,请从您的终端或命令提示符运行以下命令。

ng new Checklist --collection @nativescript/schematics --shared --sample

让我们分解一下这里发生了什么。

  • ng new 是 Angular CLI 用于启动新 Angular 应用的命令。
  • Checklist 是您的应用名称。对于您自己的应用,您需要在此处提供您自己的值。
  • --collection @nativescript/schematics 标志告诉 Angular CLI 关于 NativeScript schematics 的信息,这使得该应用可以通过 NativeScript 在 iOS 和 Android 上运行。
  • --shared 标志告诉 NativeScript schematics 您希望使用代码共享项目模板开始。
  • --sample 标志告诉 NativeScript schematics 搭建一些示例组件。当您开始构建自己的应用时,您可能希望省略此标志。

现在您已经有了应用,让我们看看如何运行它。

运行您的应用

首先,使用 cd 命令进入您刚刚构建的新应用目录。

cd Checklist

接下来,运行 npm install 以安装应用所需的依赖项。

npm install

从这里开始,您可以使用三个不同的命令来运行您的应用。

首先,ng serve 是您在 Web 上运行新创建的应用的方式。请从您的终端或命令提示符运行此命令。

ng serve

该命令完成后,您将看到一条关于 Angular 实时开发服务器正在监听的消息。

terminal

如果您按照说明操作并在浏览器中访问 localhost:4200,您将看到默认的 Web 应用正在运行,这是一个显示足球运动员的简单主从应用。

default-web-app

如果您之前做过任何 Angular 开发,这将感觉非常熟悉,因为它与您用于构建 Angular Web 应用的工作流程相同——这很酷!

但是您使用此工作流程不仅仅是为了构建 Web 应用,当您将 NativeScript 引入其中时,真正的魔力就出现了。

但在您在 iOS 和 Android 上运行此应用之前,我需要提醒您:因为 NativeScript 应用是真正的原生 iOS 和 Android 应用,所以在您的开发机器上需要安装一组额外的系统要求才能构建这些应用。请查看 NativeScript 文档中的此页面,了解您需要完成的必要设置步骤。

注意:下一个版本的 NativeScript,NativeScript 5.0,有一些有趣的更改,将允许您在没有任何本地设置的情况下运行应用。您可以在 GitHub 上了解更多信息

设置完成后,返回到您的终端或命令提示符,使用 Ctrl + C 停止您的 ng serve 命令,然后执行 npm run android。该命令需要运行一段时间,因为 NativeScript 在后台构建了一个完全原生的 Android 应用。完成后,您将看到以下屏幕。

android-1 


如果您在 macOS 上进行开发,您也可以尝试运行 npm run ios,它会经历类似的过程,但会构建并在 iOS 上启动您的应用。完成后,您将看到此屏幕。

ios-1 


使用单个代码库和一组简单的命令,您现在可以在三个地方运行相同的应用。

很酷,对吧?

现在您已经知道如何运行您的应用,让我们深入了解应用的代码,看看发生了什么。

浏览代码

从项目的根目录开始,以下是要了解的主要顶级文件夹。

Checklist/
├── App_Resources
├── platforms
└── src
  • App_Resources 文件夹是 NativeScript 存储 iOS 和 Android 配置文件以及图像资源(如应用的图标和启动画面)的地方。在后续的应用开发中,您需要使用 NativeScript CLI 的 tns resources generate 命令切换到您自己的图像资源。
  • platforms 文件夹是 NativeScript 存储生成的 iOS 和 Android 应用的地方;您可以将其视为原生项目的 dist 文件夹。
  • src 文件夹是您的源代码所在的位置,您将在这里花费 95% 的时间。

注意:根文件夹中还有许多其他配置文件,例如用于 Angular CLI 自定义的 angular.json 文件、用于管理依赖项的 package.json 文件以及一系列用于配置 TypeScript 的 tsconfig.json 文件。在开始时,最好不要修改这些文件;您可以稍后返回并自定义它们以满足项目的需要。

由于 src 文件夹是您将花费大部分时间的地方,因此让我们更详细地了解该文件夹的内容。

src/
├── app
│   ├── app.component.css
│   ├── app.component.html
│   ├── app.component.tns.html
│   ├── app.component.ts
│   ├── app.module.tns.ts
│   ├── app.module.ts
│   ├── app.routes.ts
│   ├── app.routing.tns.ts
│   ├── app.routing.ts
│   └── barcelona
│       └── ...
├── app.css
├── assets
├── index.html
├── main.tns.ts
├── main.ts
├── package.json
└── styles.css

如果您之前构建过 Angular Web 应用,许多结构将看起来非常熟悉。所有 Angular 应用都有一个用于初始化的 main.ts 文件、一个用于 模块声明app.module.ts 文件、一系列用于 设置路由app.routing.ts 文件以及一个用作 应用第一个组件app.component.ts 文件。

提示:如果您不熟悉 Angular,并且想要更深入地了解这些概念,请查看 Angular 快速入门教程。您在那里学到的所有概念都直接适用于本文的代码共享结构。

NativeScript 代码共享工作流程中一个独特的概念是您在某些应用文件中看到的 .tns 命名约定(例如 app.routing.tns.ts)。

默认情况下,NativeScript schematics 将所有项目的代码都包含在 Web 和移动应用中——毕竟,您正在尝试共享代码。但是,在某些情况下,您需要创建 Web 和移动特定的文件,这就是 .tns 扩展名发挥作用的地方。

例如,以 app.module.tsapp.module.tns.ts 文件为例。当您在 Web 上运行应用时,Angular CLI 会按照您的预期使用 app.module.ts 文件。但是,当您在 iOS 或 Android 上运行应用时,NativeScript schematics 会改为获取并使用 app.module.tns.ts 文件。此约定是根据需要拆分 Web 和移动代码的强大方法,并且在使用此设置构建自己的应用时,您将经常使用它。

现在您已经了解了项目的结构,让我们构建一些新的东西。

构建您的 Web UI

在入门应用中,大部分代码位于 src/app/barcelona 文件夹中,因为这是构建您之前在应用中看到的球员列表的代码。在本节中,您将为 Web 创建一个全新的组件,在下一节中,您将看到让同一个组件在原生 iOS 和 Android 应用中运行是多么容易。

让我们从搭建一些文件开始。为此,首先使用 cd 命令导航到您的 src/app 文件夹。

cd /src/app

接下来,创建一个名为 list 的新文件夹,并在该文件夹中创建以下文件。

. app
└── list
    ├── list.common.ts
    ├── list.component.css
    ├── list.component.html
    ├── list.component.ts
    ├── list.module.ts
    └── list.service.ts

提示:当您更习惯使用 NativeScript schematics 后,可以使用一系列命令来帮助生成组件和模块。请参阅 NativeScript schematics 文档 以获取更多信息。

以下是作为第一步在这些文件中放入的内容。不必太在意这段代码的具体含义,因为我们稍后会讨论需要注意的重要事项。

list.common.ts

import { Routes } from '@angular/router';

import { ListComponent } from './list.component';
import { ListService } from './list.service';

export const COMPONENT_DECLARATIONS: any[] = [
  ListComponent
];

export const PROVIDERS_DECLARATIONS: any[] = [
  ListService
];

export const ROUTES: Routes = [
  { path: 'list', component: ListComponent },
];

list.component.css

ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

li {
  border-style: solid;
  border-width: 0 0 1px 0;
  border-color: #C0C0C0;
  display: flex;
  align-items: center;
  cursor: pointer;
}

li.selected {
  background-color: #C0C0C0;
}

img {
  height: 96px;
  width: 96px;
}

list.component.html

<ul>
  <li *ngFor="let item of items" [class.selected]="item.selected" (click)="itemTapped(item)">
    <img [src]="item.image">
    <span>{{ item.name }}</span>
  </li>
</ul>

list.component.ts

import { Component, OnInit } from '@angular/core';

import { ListService } from './list.service';

@Component({
  selector: 'app-list',
  templateUrl: './list.component.html',
  styleUrls: ['./list.component.css']
})
export class ListComponent implements OnInit {
  items: any[];

  constructor(private listService: ListService) { }

  ngOnInit() {
    this.listService.get().subscribe((data: any) => {
      this.items = data;
    });
  }

  itemTapped(item) {
    this.listService.toggleSelected(item);
  }
}

list.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule } from '@angular/router';

import { ROUTES, COMPONENT_DECLARATIONS, PROVIDERS_DECLARATIONS } from './list.common';

@NgModule({
  imports: [
    CommonModule,
    HttpClientModule,
    RouterModule.forRoot(ROUTES)
  ],
  exports: [
    RouterModule
  ],
  declarations: [
    ...COMPONENT_DECLARATIONS
  ],
  providers: [
    ...PROVIDERS_DECLARATIONS
  ]
})
export class ListModule { }

list.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators';

@Injectable()
export class ListService {
  saved: any[];

  constructor(private http: HttpClient) {
    let saved = localStorage.getItem('items');
    if (saved) {
      this.saved = JSON.parse(saved);
    } else {
      this.saved = [];
    }
  }

  get() {
    return this.http.get('https://rawgit.com/tjvantoll/ShinyDex/master/assets/151.json')
      .pipe(
        map((data: any) => {
          const returnData = [];
          data.results.forEach((item) => {
            item.selected = this.saved.indexOf(item.id) != -1;
            returnData.push(item);
          })
          return returnData;
        })
      );
  }

  toggleSelected(item) {
    if (item.selected) {
      this.saved.splice(this.saved.indexOf(item.id), 1);
    } else {
      this.saved.push(item.id);
    }

    item.selected = !item.selected;
    this.save();
  }

  save() {
    localStorage.setItem('items', JSON.stringify(this.saved));
  }
}

同样,如果您不理解这段代码中发生的所有事情,也不要担心。现在,您只需要知道这是一个相当简单的 Angular 组件,它从 API 加载数据并在列表中显示它。

要激活这个新组件以便尝试,首先,将这个新组件添加到您的app.module.ts文件中,以便 Angular 了解它。以下是您的新app.module.ts文件应有的样子。

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app.routing';
import { AppComponent } from './app.component';
import { ListModule } from './list/list.module';

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ListModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

接下来,更改app.routes.ts文件中的默认路径,以便 Angular 默认导航到新的列表组件。新的app.routes.ts文件应如下所示。

import { Routes } from '@angular/router';

export const ROUTES: Routes = [
  { path: '', redirectTo: '/list', pathMatch: 'full' },
];

最后,为了使这个应用看起来更漂亮一些,将以下代码粘贴到您的src/styles.css文件中,这是您为 Angular 应用添加全局 CSS 的地方。

html, body { margin: 0; }
body {
  font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}

完成这些操作后,返回到您的终端或命令提示符并运行ng serve。命令运行后,打开您的浏览器并访问localhost:4200,您现在应该会看到一个简单的清单应用,如下所示。

web-in-action

您现在拥有的应用是一个非常简单的应用,允许用户选择项目。如果您查看list.service.ts中的代码,您会发现该应用还使用localStorage记住用户的选择——这意味着,当用户返回应用时,所有选择都会保留。

此时,您拥有了一个非常简单的移动应用。如果您愿意,可以参考许多在线指南,例如为该应用添加服务工作线程并将其转变为渐进式 Web 应用——毕竟,目前您只是使用 Angular CLI 构建 Web 应用。

但是,此工作流程真正的乐趣在于,将像这样的功能应用转换为原生 iOS 和 Android 应用是多么容易。让我们看看如何做到这一点。

创建您的 iOS 和 Android 应用

使用 NativeScript 脚本来将 Web 界面转换为移动 UI 时,您的首要任务是确定哪些代码可以重用,哪些代码不能重用。让我们回到构成此组件的文件列表。

. app
└── list
    ├── list.common.ts
    ├── list.component.css
    ├── list.component.html
    ├── list.component.ts
    ├── list.module.ts
    └── list.service.ts

对于此示例,您需要创建一个特定于 NativeScript 的标记文件 (.html)、样式文件 (.css) 和模块文件 (.module.ts)。为此,请继续创建三个新文件,list.component.tns.csslist.component.tns.htmllist.module.tns.ts。您的文件树现在应该如下所示。

. app
└── list
    ├── list.common.ts
    ├── list.component.css
    ├── list.component.html
    ├── list.component.tns.css (new)
    ├── list.component.tns.html (new)
    ├── list.component.ts
    ├── list.module.ts
    ├── list.module.tns.ts (new)
    └── list.service.ts

请记住,Angular CLI 会在为 NativeScript 构建时自动获取 .tns.* 文件中的代码,在为 Web 构建时自动获取非 .tns 文件中的代码。因此,.tns 文件是您需要放置特定于 NativeScript 的代码的地方。

为此,请首先打开您的list.module.tns.ts文件并粘贴以下代码。

import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { NativeScriptCommonModule } from 'nativescript-angular/common';
import { NativeScriptHttpClientModule } from 'nativescript-angular/http-client';
import { NativeScriptRouterModule } from 'nativescript-angular/router';

import { ROUTES, COMPONENT_DECLARATIONS, PROVIDERS_DECLARATIONS } from './list.common';

@NgModule({
  imports: [
    NativeScriptCommonModule,
    NativeScriptHttpClientModule,
    NativeScriptRouterModule,
    NativeScriptRouterModule.forRoot(ROUTES)
  ],
  exports: [
    NativeScriptRouterModule
  ],
  declarations: [
    ...COMPONENT_DECLARATIONS
  ],
  providers: [
    ...PROVIDERS_DECLARATIONS
  ],
  schemas: [
    NO_ERRORS_SCHEMA
  ]
})
export class ListModule { }

您需要一个特定于 NativeScript 的模块文件,以便您可以声明特定于 NativeScript 的导入,例如NativeScriptHttpClientModuleNativeScriptRouterModule。但是,请注意您的list.module.tslist.module.tns.ts文件如何从您的list.common.ts文件中提取路由、声明和提供程序。这使您能够在一个地方添加这些声明,而无需每次需要进行小更新时都更改两个不同的模块文件。

下一个要更改的文件是您的应用的标记文件。为此,请打开您的list.component.tns.html文件并粘贴以下代码。

<ListView [items]="items">
  <ng-template let-item="item">
    <FlexboxLayout [class.selected]="item.selected" (tap)="itemTapped(item)">
      <Image [src]="item.image"></Image>
      <Label [text]="item.name"></Label>
    </FlexboxLayout>
  </ng-template>
</ListView>

提示:如果您需要学习这些 NativeScript 用户界面组件的帮助,请尝试使用NativeScript Playground,特别是尝试屏幕左下角的组件窗格。

接下来,要为这些组件设置样式,请将以下代码粘贴到您的list.component.tns.css文件中。

FlexboxLayout {
  padding: 5;
  align-items: center;
}
.selected {
  background-color: #C0C0C0;
}
Image {
  height: 80;
  width: 80;
}

提示:如果您是 SASS 的粉丝,则可以将其与 NativeScript 脚本一起使用并共享 CSS 变量(例如颜色)。查看NativeScript 文档中的说明,了解您需要采取的后续步骤。

完成所有这些操作后,您最后一步是在您的app.module.tns.ts文件中导入您的ListModule,就像您对app.module.ts文件所做的那样。为此,请将app.component.tns.ts文件的内容替换为以下代码。

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

import { AppRoutingModule } from './app.routing';
import { AppComponent } from './app.component';
import { ListModule } from './list/list.module';

@NgModule({
  bootstrap: [
    AppComponent
  ],
  imports: [
    NativeScriptModule,
    AppRoutingModule,
    ListModule
  ],
  declarations: [
    AppComponent
  ],
  providers: [
  ],
  schemas: [
    NO_ERRORS_SCHEMA
  ]
})
export class AppModule { }

这样一来,您应该拥有一个功能正常的 NativeScript 应用,对吧?实际上,您还需要进行最后一次更改,为了展示它,让我们介绍一下辅助文件这一概念。

使用辅助文件

在采用代码共享方法时,有时您需要完全拆分 Web 和原生的实现。例如,您的用户界面始终需要不同的实现,因为您需要为 Web 使用 DOM 节点,为移动使用 NativeScript 用户界面控件。

但是,通常情况下,您可以共享几乎所有代码,但需要为 Web 和移动提供略微不同的实现。在您的示例应用中实际上有一个这样的例子,它位于您的list.service.ts文件中。

如果您打开list.service.ts,您会看到两个不同的localStorage引用——一个在构造函数中……

let saved = localStorage.getItem('items');

……另一个在save()方法中。

localStorage.setItem('items', JSON.stringify(this.saved));

这里的问题是localStorage是一个浏览器 API。它在 Web 上运行良好,但在 NativeScript 应用中不存在,因为 NativeScript 应用不在浏览器中运行。

您可以通过几种不同的方式处理此问题。您可以创建一个list.service.tns.ts文件,并创建此服务的单独实现,该实现适用于您的移动应用。但是,如果您这样做,则需要复制许多在两个平台上都相同的代码,例如通过 HTTP 调用后端并解析数据的代码。

当您遇到这些场景时,您还可以选择创建辅助文件。也就是说,创建两个具有相同 API 的文件,并将这些 API 的 Web 实现放在一个文件中,将这些 API 的 NativeScript 实现放在另一个文件中。

要为您的服务执行此操作,请创建两个名为list.helper.tslist.helper.tns.ts的新文件。您的新文件夹结构现在应该如下所示。

. app
└── list
    ├── list.common.ts
    ├── list.component.css
    ├── list.component.html
    ├── list.component.tns.css
    ├── list.component.tns.html
    ├── list.component.ts
    ├── list.helper.tns.ts (new)
    ├── list.helper.ts (new)
    ├── list.module.ts
    ├── list.module.tns.ts
    └── list.service.ts

提示:在实际应用中,您可能希望将服务及其辅助文件移动到其自己的文件夹中,这既是为了分解文件夹结构,也是为了使服务可重用。但是,对于本教程,我们将所有内容保存在一个地方最简单。

打开您的list.helper.ts文件并粘贴以下代码。这是您服务中提取到辅助程序中的相同localStorage代码。

export class ListHelper {
  readItems() {
    let saved = localStorage.getItem('items');
    if (saved) {
      return JSON.parse(saved);
    } else {
      return [];
    }
  }

  writeItems(items) {
    localStorage.setItem('items', JSON.stringify(items));
  }
}

接下来,打开您的list.helper.tns.ts文件并粘贴以下代码。此代码遵循与 Web 辅助程序相同的 API,但使用 NativeScript 的一些内置模块为您的 iOS 和 Android 应用完成相同的任务。

import { knownFolders, File } from 'tns-core-modules/file-system';

export class ListHelper {
  saveFile: File;

  constructor() {
    this.saveFile = knownFolders.documents().getFile('items.json');
  }

  readItems() {
    const items = this.saveFile.readTextSync();
    return items ? JSON.parse(items) : [];
  }

  writeItems(items) {
    this.saveFile.writeText(JSON.stringify(items));
  }
}

您在此处的最后一步是更改您的服务以利用这些新的辅助程序,您可以通过将list.service.ts文件中的代码替换为以下代码来完成此操作,该代码利用了新的辅助程序。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators';

import { ListHelper } from './list.helper';

@Injectable()
export class ListService {
  saved: any[];
  helper: ListHelper;

  constructor(private http: HttpClient) {
    this.helper = new ListHelper();
    this.saved = this.helper.readItems();
  }

  get() {
    return this.http.get('https://rawgit.com/tjvantoll/ShinyDex/master/assets/151.json')
      .pipe(
        map((data: any) => {
          const returnData = [];
          data.results.forEach((item) => {
            item.selected = this.saved.indexOf(item.id) != -1;
            returnData.push(item);
          })
          return returnData;
        })
      );
  }

  toggleSelected(item) {
    if (item.selected) {
      this.saved.splice(this.saved.indexOf(item.id), 1);
    } else {
      this.saved.push(item.id);
    }

    item.selected = !item.selected;
    this.save();
  }

  save() {
    this.helper.writeItems(this.saved);
  }
}

现在您已准备好所有代码,请使用以下命令之一在 iOS 或 Android 上运行您的应用。

npm run ios
npm run android

您应该会看到一个如下所示的应用。

apps-in-action

尽管此应用很简单,但请记住您在这里看到的内容:这些是原生 iOS 和 Android 应用,使用原生 iOS 和 Android 用户界面控件。而且,您不仅使用 Angular 和 TypeScript 构建了这些应用,而且还在所有三个平台上共享了大量代码。

注意:您可以从NativeScript 文档中的这篇文章中了解有关使用辅助文件拆分代码的更多信息。

总体概览

在本文中,我们研究了如何构建一个简单的组件,该组件可在 Web 和原生应用之间共享代码。虽然我们专注于本文中的一个组件,但这种方法足够灵活,可以构建任何规模的应用。

根据您的需求,您可能希望为 Web 和移动创建相对相同的应用。或者,您可能希望创建非常不同的应用,这些应用共享相同的语言、框架、底层基础架构和服务层。

毕竟,使用 NativeScript 脚本的强大之处不仅在于代码共享——您还可以通过使用一种语言和一个框架为三个平台构建来获得大量收益。希望您和我一样对使用此技术栈可以构建的出色事物感到兴奋。

资源