第05课:创建页面和使用服务

第05课:创建页面和使用服务

在完成了两个组件 ItemsListComponent 和 ItemComponent 的开发和测试之后,就可以着手开发页面。

新闻列表页面

第一个页面是新闻列表页面,也是实例应用的初始页面。在这里,我们直接使用骨架代码已有的 home 页面。在 HomePage 中,手动创建了只包含一个测试条目的 ItemsList 对象。

import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { ItemsList } from '../../models/item';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  itemsList: ItemsList;

  constructor(public navCtrl: NavController) {
    this.itemsList = {
      items: [
        {
          title: '测试标题',
          author: '作者',
          date: '2017-12-27',
          id: '1',
          url: 'http://example.com/1',
        }
      ]
    };
  }

}

HomePage 页面对应的模板文件 home.html 也进行修改来使用 quwen-items-list 组件。

<ion-header>
  <ion-navbar>
    <button ion-button menuToggle>
      <ion-icon name="menu"></ion-icon>
    </button>
    <ion-title>首页</ion-title>
  </ion-navbar>
</ion-header>

<ion-content padding>
  <quwen-items-list [itemsList]="itemsList"></quwen-items-list>
</ion-content>

经过这样的修改之后,实例应用的初始页面展示了唯一的条目。

创建服务

我们在前面提过,Angular 应用中与业务逻辑相关的代码都应该封装在服务中。因此,我们需要创建服务来获取条目列表。

下面的代码是 src/services/ItemsListService.ts 文件的内容。服务相关的文件一般放在对应的 services 目录下。ItemsListService 的实现很简单,复制了之前在 HomePage 中的代码逻辑。不过 load 方法返回的是一个 Promise<ItemsList> 对象,这是因为加载条目本身是一个异步行为。Promise.resolve 使用一个固定值作为 Promise 完成之后的结果。

import { ItemsList } from "../models/item";

export class ItemsListService {
  load(): Promise<ItemsList> {
    return Promise.resolve({
      items: [
        {
          title: '测试标题',
          author: '作者',
          date: '2017-12-27',
          id: '1',
          url: 'http://example.com/1',
        }
      ]
    });
  }
}

相应的 HomePage 也需要进行修改来使用服务。只需要在 HomePage 的构造方法中添加 private itemsListService: ItemsListService,Angular 会自动把 ItemsListService 的实例注入到 HomePage 中。 HomePage 实现了 Angular 中的 OnInit 接口,并把加载条目的逻辑放在对应的 ngOnInit 方法中。

import { Component, OnInit } from '@angular/core';
import { NavController } from 'ionic-angular';
import { ItemsList } from '../../models/item';
import { ItemsListService } from "../../services/ItemsListService";

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage implements OnInit {
  itemsList: Promise<ItemsList>;

  constructor(public navCtrl: NavController, private itemsListService: ItemsListService) {

  }

  ngOnInit(): void {
    this.itemsList = this.itemsListService.load();
  }
}

由于 itemsList 的类型变成了Promise<ItemsList>,在模板中需要使用Angular中的管道 async 来提取 Promise 完成之后的值。

<quwen-items-list [itemsList]="itemsList | async"></quwen-items-list>

另外一个改动是, ItemsListComponent 中的输入属性 itemsList 的值现在有可能为 null ,因此需要在模板中使用 ngIf 进行检查。

在 AppModule 的 providers 数组中也需要添加 ItemsListService ,否则新加的服务无法被识别。

测试页面

基于与组件相同的实践,我们需要为页面添加单元测试。由于页面 HomePage 使用了服务 ItemsListService ,我们也需要提供该服务的mock实现。 ItemsListServiceMock 中提供了 load 和 loadSync 两个方法:load 方法是异步的,而 loadSync 方法是同步的。ItemsListComponent 的测试套件也改成使用 loadSync 方法。

import { Item, ItemsList } from "../models/item";
import { ItemsListService } from "../services/ItemsListService";

function createItems(n: number): Item[] {
  const items = [];
  for (let i = 0; i < n; i++) {
    items.push({
      id : `item-${i}`,
      title: `Item ${i}`,
      author: `Author ${i}`,
      url: `http://example.com/${i}`,
      date: '2017-12-27',
    });
  }
  return items;
}

export class ItemsListServiceMock {
  loadSync(): ItemsList {
    return {
      items: createItems(10),
    };
  }
  load(): Promise<ItemsList> {
    return Promise.resolve({
      items: createItems(10),
    });
  }
}

HomePage 的测试套件 home.spec.ts 的内容如下所示。该套件中使用了 TestUtils.beforeEachCompiler 方法的第二个参数来提供额外的服务提供者。 {provide: ItemsListService, useClass: ItemsListServiceMock} 的含义是使用类 ItemsListServiceMock 作为 ItemsListService 的实现。因此 Angular 在测试时会注入 ItemsListServiceMock 的实例给 HomePage。

测试规格的实现和之前的 ItemsListComponent 不太一样,这是因为 HomePage 中使用了异步服务。第一个 fixture.detectChanges() 的作用是触发组件的创建。 fixture.whenStable() 的作用是等待异步服务完成,返回的是一个 Promise 对象。在该 Promise 对象的 then 处理中, fixture.detectChanges() 的作用是触发 HomePage 使用服务返回的数据更新 DOM,接着进行验证。

import { async, ComponentFixture } from "@angular/core/testing";
import { TestUtils } from "../../test-utils";
import { HomePage } from "./home";
import { ItemsListComponent } from "../../components/items-list/items-list";
import { ItemComponent } from "../../components/item/item";
import { ItemsListServiceMock } from "../../testing/ItemsListServiceMock";
import { ItemsListService } from "../../services/ItemsListService";
import { By } from "@angular/platform-browser";

describe('HomePage', () => {
  let fixture: ComponentFixture<HomePage>;
  let component: HomePage;

  beforeEach(async(() => TestUtils.beforeEachCompiler(
    [HomePage, ItemsListComponent, ItemComponent],
    [{provide: ItemsListService, useClass: ItemsListServiceMock}]).then((result) => {
    fixture = result.fixture;
    component = result.component;
  })));

  it('should display items', async(() => {
    fixture.detectChanges();
    fixture.whenStable().then(() => {
      fixture.detectChanges();
      let items = fixture.debugElement.queryAll(By.css('quwen-item'));
      expect(items.length).toEqual(10);
    });
  }));
});

实现服务

目前我们的 ItemsListService 只是简单的返回一个固定的条目,接着我们需要实现真正的服务,即调用外部 API 来获取数据。本实例的数据来自于易源数据。由于 API 提供者限制了调用的次数,而且数据的更新也是有间隔的,本实例实际上调用的是存放在自身的服务器上的缓存文件。

为了在 Ionic 应用中执行 HTTP 请求,需要使用第三方插件。Ionic Native 中已经包装了 HTTP 插件,可以直接使用。首先需要使用 ionic cordova plugin add cordova-plugin-advanced-http 来添加 Cordova 插件 cordova-plugin-advanced-http ,接着安装对应的依赖 @ionic-native/http 。

$ ionic cordova plugin add cordova-plugin-advanced-http
$ yarn add @ionic-native/http

需要在 AppModule 的 providers 添加来自 @ionic-native/http 的服务提供者 HTTP。

实例首页的数据来自新闻查询 API 并缓存在路径 https://vividcode.io/quwen/news.json 。下面的代码是更新之后的 ItemsListService 。在构造方法中注入了两个服务,分别是 Ionic 提供的 Platform 和 HTTP 插件提供的 HTTP。 Platform 的作用是与底层平台进行交互。 Platform 的 ready 方法返回的 Promise 对象表示底层平台是否已经可用。使用 Cordova 插件的代码只有在平台可用后才能调用。HTTP 服务的 get 方法用来发送GET请求,返回的也是一个 Promise 对象。当请求成功之后,Promise 中包含的是 HTTP 响应内容,其中的 data 属性是 HTTP 响应体内容。这里使用 JSON.parse 来把响应体内容解析成 JSON 对象。showapiresbody.pagebean.contentlist 获取到响应中包含的新闻条目数组,并使用 map 方法把每个条目转换成 Item 的格式。

import { ItemsList } from "../models/item";
import { Injectable } from "@angular/core";
import { HTTP } from '@ionic-native/http';
import { Platform } from "ionic-angular";

@Injectable()
export class ItemsListService {

  constructor(private platform: Platform, private http: HTTP) {}

  load(): Promise<ItemsList> {
    return this.platform.ready().then(() => {
      return this.http.get('https://vividcode.io/quwen/news.json', {}, {}).then(res => {
        let data = JSON.parse(res.data);
        return {
          items: data.showapi_res_body.pagebean.contentlist.map(item => ({
            id: item.id,
            title: item.title,
            author: item.source,
            date: item.pubDate,
            url: item.link,
          })),
        };
      });
    });
  }
}

修饰符 @Injectable() 的作用是告诉 Angular, ItemsListService 需要注入其他服务。

下图是在 iPhone X 模拟器上运行当前实例应用的效果。

新闻列表页面

创建“微信精选”页面

接着创建实例应用的第二个页面“微信精选”。该页面的数据与首页的来源相同,也同样缓存在自身的服务器上。两者的区别在于返回的数据格式不同,因此转换为 Item 的逻辑也不相同。为了复用已有的代码,对 ItemsListService 进行适当的重构,新的实现如下所示。

在新的实现中,load 方法添加了两个参数:url 是请求的 URL,而 converter 则是一个把响应体内容转换成 Item[] 的函数。经过重构之后, ItemsListService 的使用就更加灵活。

import { Item, ItemsList } from "../models/item";
import { Injectable } from "@angular/core";
import { HTTP } from '@ionic-native/http';
import { Platform } from "ionic-angular";

@Injectable()
export class ItemsListService {

  constructor(private platform: Platform, private http: HTTP) {}

  load(url: string, converter: (data: any) => Item[]): Promise<ItemsList> {
    return this.platform.ready().then(() => {
      return this.http.get(url, {}, {}).then(res => {
        let data = JSON.parse(res.data);
        return {
          items: converter(data),
        };
      });
    });
  }
}

之前在 ItemsListService 中的转换逻辑,现在移到了具体的页面中。下面是 HomePage 中的 ngOnInit 方法的新实现。

ngOnInit(): void {
  this.itemsList = this.itemsListService.load('https://vividcode.io/quwen/news.json', data => {
    return data.showapi_res_body.pagebean.contentlist.map(item => ({
      id: item.id,
      title: item.title,
      author: item.source,
      date: item.pubDate,
      url: item.link,
    }));
  });
}

接着我们删除骨架代码中原有的 list 页面所对应的内容,并使用 ionic generate page 命令来创建新的页面。Ionic命令行工具会在 pages/wechat-popular 目录下创建组件类 WechatPopularPage 和其他相关内容。

$ ionic generate page wechatPopular --no-module    

新创建的 WechatPopularPage 类需要在 AppModule 中进行注册。同时把 MyApp 中的 ListPage 替换成 WechatPopularPage。

WechatPopularPage 的实现与 HomePage 很类似,只不过在对 API 响应的处理逻辑上有所不同。

import { Component, OnInit } from '@angular/core';
import { NavController } from 'ionic-angular';
import { ItemsList } from "../../models/item";
import { ItemsListService } from "../../services/ItemsListService";

@Component({
  selector: 'page-wechat-popular',
  templateUrl: 'wechat-popular.html',
})
export class WechatPopularPage implements OnInit {
  itemsList: Promise<ItemsList>;

  constructor(public navCtrl: NavController, private itemsListService: ItemsListService) {
  }

  ngOnInit(): void {
    this.itemsList = this.itemsListService.load('https://vividcode.io/quwen/wechat.json', data => {
      return data.showapi_res_body.newslist.map(item => ({
        id: `${new Date().getTime()}`,
        title: item.title,
        author: item.description,
        date: item.ctime,
        url: item.url,
      }));
    });
  }

}

模板 wechat-popular.html 的内容也是和 home.html 类似的。

<ion-header>
  <ion-navbar>
    <button ion-button menuToggle>
      <ion-icon name="menu"></ion-icon>
    </button>
    <ion-title>微信精选</ion-title>
  </ion-navbar>
</ion-header>

<ion-content padding>
  <quwen-items-list [itemsList]="itemsList | async"></quwen-items-list>
</ion-content>

同样可以在模拟器中查看新页面的运行效果。

微信精选页面效果

加载提示和错误处理

应用的两个页面都需要从服务器端获取数据。数据获取的过程可能耗时比较长,而且会出现错误。因此,我们需要在页面上添加加载提示,并对可能出现的错误进行处理。由于 ItemsListService 的 load 方法返回的是一个 Promise 对象,可以使用该对象来判断加载是否完成或出现错误,并添加相应的处理逻辑。

Ionic 提供了内置的 LoadingController 组件来显示加载提示。错误信息可以使用 ToastController 来显示。下面的代码是更新之后的 HomePage。在构造方法中注入了 LoadingController 和 ToastController。在加载之前,使用 LoadingController 的 create 方法来创建一个 Loading 组件。创建时的配置项 content 表示显示的文本内容。Loading 的 present 方法用来显示加载提示。对于 ItemsListService 的 load 方法返回的 Promise 对象,在其 then 方法中使用 Loading 的 dismiss 方法来关闭加载提示。在 catch 方法中,同样需要关闭加载提示。除此之外,还通过 ToastController 的 create 方法来创建一个 Toast 组件并通过 present 方法来显示它。ToastController 的 create 方法有很多配置项,如下。

  • message:表示显示的文本内容。
  • position:表示显示的位置,可以有值 top、middle 和 bottom。
  • showCloseButton:表示是否显示关闭按钮。
  • closeButtonText:表示关闭按钮的文本内容。
import { Component, OnInit } from '@angular/core';
import { LoadingController, ToastController } from 'ionic-angular';
import { ItemsList } from '../../models/item';
import { ItemsListService } from "../../services/ItemsListService";

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage implements OnInit {
  itemsList: Promise<ItemsList>;

  constructor(private loadingCtrl: LoadingController,
              private toastCtrl: ToastController,
              private itemsListService: ItemsListService) {
  }

  ngOnInit(): void {
    const loader = this.loadingCtrl.create({
      content: '加载中...',
    });
    loader.present();
    this.itemsList = this.itemsListService.load('https://vividcode.io/quwen/news.json', data => {
      return data.showapi_res_body.pagebean.contentlist.map(item => ({
        id: item.id,
        title: item.title,
        author: item.source,
        date: item.pubDate,
        url: item.link,
      }));
    }).then(result => {
      loader.dismiss();
      return result;
    }).catch(error => {
      loader.dismiss();
      this.toastCtrl.create({
        message: '加载数据失败',
        position: 'bottom',
        showCloseButton: true,
        closeButtonText: '关闭',
      }).present();
      return {
        items: [],
      };
    });
  }
}

在模拟器中运行之后,可以看到新闻条目出现前的加载提示。出现错误时,可以在屏幕下方看到出错信息。

出错信息

在创建了页面并使用服务加载后台数据之后,应用的基本骨架就完成了。在下一文中,我们将介绍如何查看网页和分享条目。

上一篇
下一篇
目录