返回博客首页
← 所有文章

NativeScript 预览 - PDF 查看器 📄🔍

2022 年 10 月 18 日 — 作者:技术指导委员会 (TSC)

我们将继续探索 NativeScript 的核心,寻找利用 NativeScript 的实用方法。我们将向您展示如何在您的移动应用程序中渲染 PDF 文件。

为了简洁起见,我们将重点关注 iOS,但如果您想让我们在另一篇博客文章中讲解 Android 版本,请在下方告知我们,因为 iOS 和 Android 版本都可以在演示中使用。

StackBlitz 演示

您可以使用手机上的“NativeScript 预览”应用程序(可在 App StoreGoogle Play 获取)扫描 StackBlitz 中的二维码。当您编辑应用程序时,这些更改将实时推送到您的手机。非常酷!

StackBlitz 演示

可重用代码在 'common.ts' 中

我们可以将平台实现之间的许多通用功能合并到单个 PDFViewCommon 类中。

//src/app/native-pdfview/common.ts

import { View, Folder, Property } from '@nativescript/core';

let tmpFolder: Folder;

export abstract class PDFViewCommon extends View {
  static loadStartEvent = 'loadStart';
  static loadEndEvent = 'loadEnd';

  src: string;

  abstract loadPDF(src: string);
}

export const srcProperty = new Property<PDFViewCommon, string>({
  name: 'src',
});
srcProperty.register(PDFViewCommon);

我们的事件名称可以共享,因此我们可以在这里定义它们。我们还将定义一个 src 属性,我们将在 PDFViewCommon 上将其注册为 Property 对象。这是一个非常重要的概念,我们稍后会解释。

首先让我们解决临时文件创建的需求。

我们需要将 PDF 保存到我们的设备上才能渲染它。我们将通过 createTempFile 方法来实现这一点。清除目录后,我们根据当前日期创建一个新文件,然后将 base64data 写入其中。将数据写入文件后,我们使用文件路径调用 loadPDF

protected createTempFile(base64data?: any) {
  return new Promise<Folder>((resolve) => {
    if (!tmpFolder) {
      tmpFolder = knownFolders.documents().getFolder('PDFViewer.temp/');
    }
    tmpFolder.clear().then(() => {
      if (base64data) {
        const file = Folder.fromPath(tmpFolder.path).getFile(
          `_${Date.now()}.pdf`
        );
        file.writeSync(base64data);
        this.loadPDF(file.path);
      } else {
        resolve(tmpFolder);
      }
    });
  });
}

重要概念:可绑定属性

在构建移动应用程序时,网络应用程序和底层原生操作系统之间存在明显的交接。NativeScript 在这方面做得非常好,使这些界限几乎消失。尽管如此,了解如何在 NativeScript 组件上注册属性以使其操作与 Angular 组件无法区分非常重要。

在下面的代码段中,我们在 NativeScript 组件上使用 Angular 绑定,以便在 pdfUrl 属性更新时触发 src 属性上的绑定。让我们花点时间讨论一下它是如何工作的。

<PDFView
  [src]="pdfUrl"
  (loadStart)="onLoadStart()"
  (loadEnd)="onLoadEnd()"
></PDFView>

要创建可绑定属性,我们实例化一个新的 Property 对象,该对象以配置对象作为参数。在本例中,我们只需将 name 属性设置为 src。我们将存储返回对象作为 srcProperty 变量,然后将该引用注册到 PDFViewCommon 类中。

export const srcProperty = new Property<PDFViewCommon, string>({
  name: 'src',
});
srcProperty.register(PDFViewCommon);

此可绑定属性随后就像组件本身上的设置函数一样工作。当 src 属性设置时,将触发 [srcProperty.setNative],我们将 value 参数传递给我们的本地 loadPDF 函数。

export class PDFView extends PDFViewCommon {
  [srcProperty.setNative](value: string) {
    this.loadPDF(value);
  }

  loadPDF(src: string) {
    // ...
  }
}

如果您像我们一样,您可能会看到 [srcProperty.setNative] 代码片段并想“嗯?”。实际上在这里发生的是 @nativescript/core 提供的东西,它允许在底层“原生”组件准备好后处理我们的类实例上的设置器,在 iOS 的情况下,它是我们的 WKWebView 实例,在我们的示例中,PDFView 代表了它。

iOS

有了通用功能,我们就可以将注意力转移到具体的实现上。因为这是 iOS,我们将使用 委托模式 来帮助我们处理一些事件委托。我们在类上定义了一个私有成员,我们将在 createNativeView 方法中实例化它。我们还定义了一个名为 ios 的 getter 函数,它返回对组件的 nativeView 实例的引用,这也为我们提供了机会将其强类型化为其纯原生平台类 WKWebView。

import { PDFViewCommon, srcProperty } from './common';

export class PDFView extends PDFViewCommon {
  private delegate: PDFWebViewDelegate;

  // @ts-ignore
  get ios(): WKWebView {
    return this.nativeView;
  }

  createNativeView() {
    // ...
  }

  [srcProperty.setNative](value: string) {
    this.loadPDF(value);
  }

  loadPDF(src: string) {
    // ...
  }
}

这里发生了很多事情,所以我们将分块地进行讲解。除了基本设置和防御之外,最重要的是要指出我们正在调用 notify 方法,该方法可用于从 Observable 派生的所有 NativeScript 类,并将 PDFViewCommon.loadStartEvent 传递给它。

loadPDF(src: string) {
  if (!src) {
    return;
  }

  let url: NSURL;

  this.notify({ eventName: PDFViewCommon.loadStartEvent, object: this });

  // ...
}

然后我们实例化 base64data 属性并通过调用 createTempFile(base64data) 创建我们的临时文件。

loadPDF(src: string) {
  // ...

  const base64prefix = 'data:application/pdf;base64,';
  if (src.indexOf(base64prefix) === 0) {
    // https://developer.apple.com/documentation/foundation/nsdata/1410081-initwithbase64encodedstring?language=objc
    const base64data = NSData.alloc().initWithBase64EncodedStringOptions(
      src.substr(base64prefix.length),
      0
    );
    this.createTempFile(base64data);
    return;
  }

  // ...
}

根据我们尝试创建临时文件的結果,我们将尝试直接从设备加载文件或进行异步调用来加载它。

loadPDF(src: string) {
  //... 

  if (src.indexOf('://') === -1) {
    url = NSURL.fileURLWithPath(src);
    this.ios.loadFileURLAllowingReadAccessToURL(url, url);
  } else {
    url = NSURL.URLWithString(src);
    this.ios.loadRequest(NSURLRequest.requestWithURL(url));
  }
}

您可以在下面看到该方法的完整代码。

loadPDF(src: string) {
  if (!src) {
    return;
  }

  let url: NSURL;

  this.notify({ eventName: PDFViewCommon.loadStartEvent, object: this });

  // detect base64 stream
  const base64prefix = 'data:application/pdf;base64,';
  if (src.indexOf(base64prefix) === 0) {
    const base64data = NSData.alloc().initWithBase64EncodedStringOptions(
      src.substr(base64prefix.length),
      0
    );
    this.createTempFile(base64data);
    return;
  }

  if (src.indexOf('://') === -1) {
    url = NSURL.fileURLWithPath(src);
    this.ios.loadFileURLAllowingReadAccessToURL(url, url);
  } else {
    url = NSURL.URLWithString(src);
    const urlRequest = NSURLRequest.requestWithURL(url);
    this.ios.loadRequest(urlRequest);
  }
}

每个 NativeScript 自定义视图组件都可以实现自己的 createNativeView,它可以返回您想要的任何平台视图。在本例中,我们可以创建我们自己的 WKWebView,并使用委托配置它以进行良好的事件处理,并设置一些配置选项以将我们的 PDF 加载到其中。

createNativeView() {
  const webView = new WKWebView({
    configuration: WKWebViewConfiguration.new(),
    frame: UIScreen.mainScreen.bounds,
  });

  this.delegate = WKWebViewDelegate.initWithOwner(new WeakRef(this));
  webView.navigationDelegate = this.delegate;

  webView.opaque = false;
  webView.autoresizingMask =
    UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
  return webView;
}

委托实现使用一种常见的模式,即使用对它拥有者的 WeakRef 静态初始化一个实例,在本例中,它将是我们的 PDFView 组件。我们实现了 WKNavigationDelegate 协议,该协议使我们能够强类型化各种方法,这些方法将为我们提供有关 WKWebView 行为的回调。非常棒。

@NativeClass()
class WKWebViewDelegate extends NSObject implements WKNavigationDelegate {
  static ObjCProtocols = [WKNavigationDelegate];
  private owner: WeakRef<PDFView>;

  static initWithOwner(owner: WeakRef<PDFView>): WKWebViewDelegate {
    const delegate = WKWebViewDelegate.new() as WKWebViewDelegate;
    delegate.owner = owner;
    return delegate;
  }

  webViewDidFinishNavigation(webView: WKWebView) {
    const owner = this.owner?.deref();
    if (owner) {
      owner.notify({
        eventName: PDFView.loadEndEvent,
        object: owner,
      });
    }
  }
}

Angular

由于我们自己的自定义 PDFView 组件处理了大部分功能,因此 Angular 实际上只需做很少的事情。我们将将其视为出色的封装!

与任何 NativeScript 组件一样,我们需要注册该元素,以便 Angular 知道它的存在,并像使用任何其他组件一样使用它。从那里,我们可以通过在加载我们选择的 PDF URL 时使用一个不错的页面阻塞加载指示器来改善用户体验。

import { Component } from '@angular/core';
import { LoadingIndicator } from '@nstudio/nativescript-loading-indicator';
import { registerElement } from '@nativescript/angular';
import { PDFView } from '../native-pdfview';
registerElement('PDFView', () => PDFView);

@Component({
  selector: 'ns-pdf-webview',
  templateUrl: './pdf-webview.component.html',
})
export class PDFWebViewComponent {
  loadingIndicator = new LoadingIndicator();
  pdfUrl =
    'https://websitesetup.org/wp-content/uploads/2020/09/Javascript-Cheat-Sheet.pdf';

  onLoadStart() {
    console.log('pdf loading started...');
    this.loadingIndicator.show({});
  }

  onLoadEnd() {
    console.log('pdf loaded!');
    this.loadingIndicator.hide();
  }
}

模板标记非常简单。

如果我们没有在长篇大论中剧透,您可能甚至不知道 PDFView 从技术上讲不是 Angular 组件 🤯

<ActionBar title="PDF Viewer"></ActionBar>

<GridLayout>
  <PDFView
    [src]="pdfUrl"
    (loadStart)="onLoadStart()"
    (loadEnd)="onLoadEnd()"
  ></PDFView>
</GridLayout>

总结

当开发人员从 StackBlitz 到手机体验实时更新时,总是会感到兴奋,并意识到原生 API 的强大功能。我们的目标是让它感觉像您已经编写的每一个 JavaScript 应用程序一样。

希望您喜欢 PDF 查看器演示。