第06课:查看网页与分享条目

第06课:查看网页与分享条目

在新闻列表页面中已经添加了每个条目的链接地址。当点击标题时,应用会自动使用系统默认的浏览器打开网址。用户可以通过系统自带的功能退回到应用界面。在 iOS 上,可以使用左上角的回退链接退回到应用;在 Android 上,可以使用回退键。使用默认的浏览器的做法虽然简单,也带来了用户体验上的问题。用户在查看新闻内容时,会跳出当前应用,打断了用户正常的使用流程。从应用本身来说,我们也希望用户尽量增加在应用上的使用时间。从这个角度来说,我们需要在应用内打开新闻网址。

查看网页

为了在应用内打开网页,需要使用到 Ionic Native 中的 In App Browser 插件。

应用内浏览器插件

与之前介绍的 HTTP 插件一样,使用该插件也需要进行安装并添加依赖。安装完成之后也需要在 AppModule 的 providers 数组中添加来自 @ionic-native/in-app-browser 的 InAppBrowser。

$ ionic cordova plugin add cordova-plugin-inappbrowser
$ yarn add @ionic-native/in-app-browser

与获取新闻列表的逻辑类似,打开网页的逻辑也应该被封装到一个服务中。服务 OpenPageService 中的方法 open(url: string): void 用来打开一个网页。OpenPageService 通过依赖注入的方法来获取 InAppBrowser 的实例。browser 用来保存创建的 InAppBrowserObject 对象。同一时间只能打开一个应用内浏览器。如果已经有一个应用内浏览器处于打开状态,则先使用 close 方法关闭它。InAppBrowser 的 create 方法打开一个 URL。create(url, target, options) 方法有 3 个参数:第一个参数 url 表示要打开的 URL,第二个参数 target 表示 URL 打开的目标,有如下的可选值:

  • _self:也是默认值。如果 URL 在应用的白名单中,则使用应用自身的 Cordova WebView 来打开 URL;否则在应用内浏览器中打开。
  • _blank:在应用内浏览器中打开。
  • _system:在系统浏览器中打开。

这里由于需要打开的 URL 可能来自任何地方,我们使用 _blank。

第三个参数 options 用来配置应用内浏览器的行为。options 的值是一个逗号分隔的配置项的列表。每个配置项是等号分隔的配置项的名称和对应的值。不同的平台所支持的配置项各不相同。比如 location 用来配置是否显示浏览器的地址栏。location 是唯一的全部平台都支持的配置项。Android 平台支持 hardwareback。当 hardwareback 的值为 yes 时,可以通过 Android 的回退键来控制应用内浏览器回退到上一页面;当值为 no 时,回退键会直接关闭应用内浏览器。在实例应用中,hardwareback=no 是我们所期望的行为。

import { InAppBrowser, InAppBrowserObject } from "@ionic-native/in-app-browser";
import { Injectable } from "@angular/core";

@Injectable()
export class OpenPageService {
  private browser: InAppBrowserObject;

  constructor(private iab: InAppBrowser) {}

  open(url: string): void {
    if (this.browser) {
      this.browser.close();
    }
    this.browser = this.iab.create(url, '_blank', 'location=no,hardwareback=no');
  }
}

在有了 OpenPageService 之后,下一步是要在组件中使用这个服务。

修改 ItemComponent

首先在 ItemComponent 的模板中,需要去掉之前的 a 元素,并添加鼠标点击的处理逻辑。当点击标题,也就是 h2 元素时,会调用 open(item.url) 。

<h2 (click)="open(item.url)">{{ item.title }}</h2>
<p class="meta">
  <span class="author">{{ item.author }}</span>
  <span class="date">{{ item.date }}</span>
</p>

ItemComponent 的代码也进行了相应的修改。新增了一个输出属性 onOpen。在调用 open 方法时,会通过 onOpen 把 URL 发布出去。Angular 中的输出属性使用 @Output 修饰符,类型是 EventEmitter。

import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Item } from '../../models/item';

@Component({
  selector: 'quwen-item',
  templateUrl: 'item.html'
})
export class ItemComponent {

  @Input() item: Item;

  @Output() onOpen = new EventEmitter<string>();

  open(url: string): void {
    this.onOpen.emit(url);
  }
}

ItemComponent 的样式也需要进行修改。使得 h2 元素看起来像是一个超链接,给用户直观的效果。$colors 是 Ionic 在 theme/variables.scss 中定义的颜色映射表,primary 是该映射表中的一个键。h2 元素被设置为使用 primary 对应的颜色。

quwen-item {
  h2 {
    cursor: pointer;
    color: map-get($colors, primary);
  }
  .meta {
    display: flex;
    justify-content: space-between;
  }
}

修改 ItemsListComponent

在 ItemsListComponent 的构造方法中需要注入 OpenPageService。onOpen 方法直接调用 OpenPageService 的 open 方法。

import { Component, Input } from '@angular/core';
import { ItemsList } from '../../models/item';
import { OpenPageService } from "../../services/OpenPageService";

@Component({
  selector: 'quwen-items-list',
  templateUrl: 'items-list.html'
})
export class ItemsListComponent {

  constructor(private openPageService: OpenPageService) {}

  @Input() itemsList: ItemsList;

  onOpen(url: string) : void {
    this.openPageService.open(url);
  }
}

在 ItemsListComponent 的模板中,(onOpen)="onOpen($event)" 为 ItemComponent 的输出属性 onOpen 添加了处理逻辑,即调用 onOpen 方法。

<ion-list *ngIf="itemsList">
  <ion-item text-wrap *ngFor="let item of itemsList.items">
    <quwen-item [item]="item" (onOpen)="onOpen($event)"></quwen-item>
  </ion-item>
</ion-list>

测试

因为增加了打开网页的逻辑,需要为 ItemsListComponent 增加新的测试。新的测试规格需要验证的是,当点击了条目的标题之后,OpenPageService 服务的 open 方法会被调用。为了能够验证方法调用,需要用到 Jasmine 提供的 Spy 对象。这里我们不需要为 OpenPageService 创建额外的 mock 对象,而是直接使用 jasmine.createSpyObj 创建一个对象,其中的 open 属性是一个Jasmine的 Spy 对象。Jasmine 创建的对象被作为 OpenPageService 的提供者注册到测试模块中。useValue 的含义是所有 OpenPageService 的实例实际上都是 openPageServiceStub 这个对象。

在测试规格中,首先拿到第一个 h2 元素对应的 DebugElement 对象,使用 triggerEventHandler 方法来触发 h2 上的点击事件,模拟点击标题的行为。fixture.debugElement.injector.get(OpenPageService) 的作用是从依赖注入器(injector)中获取到 OpenPageService 的实例,也就是之前创建的 openPageServiceStub 对象。openPageService.open 表示的是 Jasmine 的 Spy 对象。toHaveBeenCalledTimes 验证 open 方法被调用了一次,toHaveBeenCalledWith 验证 open 方法被调用时的参数是 “http://example.com/0”,也就是第一个条目的 URL。

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

describe('ItemsListComponent', () => {
  let fixture: ComponentFixture<ItemsListComponent>;
  let component: ItemsListComponent;
  let itemsListService = new ItemsListServiceMock();

  beforeEach(async(() => {
    let openPageServiceStub = jasmine.createSpyObj('openPage', ['open']);
    TestUtils.beforeEachCompiler([ItemsListComponent, ItemComponent],
      [{provide: OpenPageService, useValue: openPageServiceStub}]).then((result => {
      fixture = result.fixture;
      component = result.component;
    }))
  }));

  it('should open items', async(() => {
    component.itemsList = itemsListService.loadSync();
    fixture.detectChanges();
    let elem = fixture.debugElement.queryAll(By.css('h2'))[0];
    elem.triggerEventHandler('click', null);
    let openPageService = fixture.debugElement.injector.get(OpenPageService);
    expect(openPageService.open).toHaveBeenCalledTimes(1);
    expect(openPageService.open).toHaveBeenCalledWith('http://example.com/0');
  }));
});

在模拟器中运行应用,点击新闻的标题,就可以看到在应用内浏览器中打开的网页。在下图中,用户可以使用窗口下方的 Done 按钮来关闭窗口。

应用内浏览器中打开网页

分享条目

除了查看新闻条目的页面之外,还需要增加分享到社交网络的功能。

Social Sharing 插件

使用分享功能需要用到 Ionic Native 中的 Social Sharing 插件。首先通过标准的流程来安装插件并添加依赖。在 AppModule 的 providers 中添加 SocialSharing。

$ ionic cordova plugin add cordova-plugin-x-socialsharing
$ yarn add @ionic-native/social-sharing

与前面的 OpenPageService 相对应的,分享相关的功能也封装在对应的服务中。SharingService 的 share 方法接受两个参数: url 是分享的网址, message 是分享时添加的文本。share 方法的实现直接调用 SocialSharing 的 share(message, subject, file, url) 方法。不过 SocialSharing 的 share 方法有更多的参数。除了 url 和 message 之外,subject 表示分享的主题,在通过电子邮件分享时会用到;file 是分享的文件或图片的 URL 或本地路径。有些分享方式允许添加图片,会用到 file。在 SharingService 的实现中,只是简单的把 subject 和 file 都设为 null。

import { SocialSharing } from "@ionic-native/social-sharing";
import { Injectable } from "@angular/core";

@Injectable()
export class SharingService {

  constructor(private socialSharing: SocialSharing) {}

  share(url: string, message: string): void {
    this.socialSharing.share(message, null, null, url);
  }
}

修改 ItemsListComponent

为了让用户可以分享条目,需要添加相关的按钮来触发分享动作。这个按钮添加到 ItemsListComponent 的模板中。其中新增的部分是分享按钮。ion-button 表明是一个 Ionic 的按钮;icon-only 表明按钮中只有图标,没有文字;clear 表明按钮没有边框;item-end 表明按钮出现的位置是在 ion-item 的结束位置上,一般是在最右边。ion-icon 用来使用 Ionicons 中的图标。名字为 share 的图标在不同平台使用该平台的原生分享图标样式。

<ion-list *ngIf="itemsList">
  <ion-item text-wrap *ngFor="let item of itemsList.items">
    <quwen-item [item]="item" (onOpen)="onOpen($event)"></quwen-item>
    <button ion-button icon-only clear item-end (click)="onShare(item.url, item.title)">
      <ion-icon name="share"></ion-icon>
    </button>
  </ion-item>
</ion-list>

点击按钮会调用 onShare 方法,并传入条目的 URL 和标题。onShare 方法的实现只是简单的调用 SharingService 的 share 方法。

import { Component, Input } from '@angular/core';
import { ItemsList } from '../../models/item';
import { OpenPageService } from "../../services/OpenPageService";
import { SharingService } from "../../services/SharingService";

@Component({
  selector: 'quwen-items-list',
  templateUrl: 'items-list.html'
})
export class ItemsListComponent {

  constructor(private openPageService: OpenPageService,
              private sharingService: SharingService) {}

  @Input() itemsList: ItemsList;

  onOpen(url: string): void {
    this.openPageService.open(url);
  }

  onShare(url: string, message: string): void {
    this.sharingService.share(url, message);
  }
}

测试

我们也需要为 SharingService 添加单元测试。测试的方式与 OpenPageService 相同。

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

describe('ItemsListComponent', () => {
  let fixture: ComponentFixture<ItemsListComponent>;
  let component: ItemsListComponent;
  let itemsListService = new ItemsListServiceMock();

  beforeEach(async(() => {
    let openPageServiceStub = jasmine.createSpyObj('openPage', ['open']);
    let sharingServiceStub = jasmine.createSpyObj('sharing', ['share']);
    TestUtils.beforeEachCompiler([ItemsListComponent, ItemComponent],
      [{provide: OpenPageService, useValue: openPageServiceStub},
        {provide: SharingService, useValue: sharingServiceStub}]).then((result => {
      fixture = result.fixture;
      component = result.component;
    }))
  }));

  it('should share items', async(() => {
    component.itemsList = itemsListService.loadSync();
    fixture.detectChanges();
    let elem = fixture.debugElement.queryAll(By.css('button'))[0];
    elem.triggerEventHandler('click', null);
    let sharingService = fixture.debugElement.injector.get(SharingService);
    expect(sharingService.share).toHaveBeenCalledTimes(1);
    expect(sharingService.share).toHaveBeenCalledWith('http://example.com/0', 'Item 0');
  }));
});

在模拟器中运行应用,点击分享按钮,可以看到弹出来的系统默认的分享对话框。

分享效果图

完成了查看网页和分享条目的功能之后,整个应用的开发已经完成。在下一篇中,我们将介绍如何进行端到端测试和发布应用。

上一篇
下一篇
目录