具備離線串流的 PWA

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

發布日期:2021 年 7 月 5 日

漸進式網頁應用程式可將許多先前僅供原生應用程式使用的功能帶到網頁上。與 PWA 相關聯最顯著的功能之一,就是離線體驗。

如果能提供離線串流媒體體驗,那就更棒了。您可以透過幾種不同的方式,為使用者提供這項強化功能。不過,這會產生一個非常獨特的問題:媒體檔案可能非常大。因此,你可能會問:

  • 如何下載及儲存大型影片檔案?
  • 如何向使用者提供這項服務?

本文將討論這些問題的答案,同時參考我們建構的 Kino 示範 PWA,提供實用範例,說明如何在不使用任何功能或呈現架構的情況下,實作離線串流媒體體驗。下列範例主要用於教學,因為在大多數情況下,您應該會使用現有的媒體架構來提供這些功能。

除非您有充分的理由自行開發,否則建構支援離線串流的 PWA 會有許多挑戰。本文將介紹用於提供優質離線媒體體驗的 API 和技術。

下載及儲存大型媒體檔案

漸進式網頁應用程式通常會使用便利的 Cache API,下載並儲存提供離線體驗所需的資產,包括文件、樣式表、圖片等。

以下是在 Service Worker 中使用 Cache API 的基本範例:

const cacheStorageName = 'v1';

this.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(cacheStorageName).then(function(cache) {
      return cache.addAll([
        'index.html',
        'style.css',
        'scripts.js',

        // Don't do this.
        'very-large-video.mp4',
      ]);
    })
  );
});

雖然上述範例在技術上可行,但使用 Cache API 有幾項限制,因此不適合處理大型檔案。

舉例來說,Cache API 不會:

  • 方便你暫停及繼續下載
  • 追蹤下載進度
  • 提供適當回應 HTTP 範圍要求的方法

對任何影片應用程式而言,這些問題都是相當嚴重的限制。請參考其他可能更適合的選項。

如今,Fetch API 是一種跨瀏覽器的方式,可非同步存取遠端檔案。在我們的用途中,這項功能可讓您以串流形式存取大型影片檔案,並使用 HTTP 範圍要求,以區塊形式逐步儲存檔案。

現在您可以使用 Fetch API 讀取資料區塊,但您也需要儲存這些資料。媒體檔案可能含有大量中繼資料,例如名稱、說明、執行時間長度、類別等。

您儲存的不是單一媒體檔案,而是結構化物件,而媒體檔案只是其中一個屬性。

在這種情況下,IndexedDB API 是儲存媒體資料和中繼資料的絕佳解決方案。可輕鬆保存大量二進位資料,並提供索引,方便您快速執行資料查詢。

使用 Fetch API 下載媒體檔案

我們在示範 PWA 中建構了幾個與 Fetch API 相關的有趣功能,並將其命名為 Kino原始碼已公開,歡迎查看。

  • 可暫停及繼續下載未完成的內容。
  • 用於在資料庫中儲存資料區塊的自訂緩衝區。

在說明如何實作這些功能之前,我們先快速回顧如何使用 Fetch API 下載檔案。

/**
 * Downloads a single file.
 *
 * @param {string} url URL of the file to be downloaded.
 */
async function downloadFile(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  do {
    const { done, dataChunk } = await reader.read();
    // Store the `dataChunk` to IndexedDB.
  } while (!done);
}

請注意,await reader.read() 是否處於迴圈中?這樣一來,您就能在可讀取的串流中接收來自網路的資料區塊。請考慮這項功能的實用性:即使資料尚未全數從網路傳輸完畢,您也能開始處理資料。

繼續下載

如果下載作業暫停或中斷,系統會將已收到的資料區塊安全地儲存在 IndexedDB 資料庫中。接著,您可以在應用程式中顯示按鈕,讓使用者繼續下載。由於 Kino 示範 PWA 伺服器支援 HTTP 範圍要求,因此恢復下載作業相當簡單:

async downloadFile() {
  // this.currentFileMeta contains data from IndexedDB.
  const { bytesDownloaded, url, downloadUrl } = this.currentFileMeta;
  const fetchOpts = {};

  // If we already have some data downloaded,
  // request everything from that position on.
  if (bytesDownloaded) {
    fetchOpts.headers = {
      Range: `bytes=${bytesDownloaded}-`,
    };
  }

  const response = await fetch(downloadUrl, fetchOpts);
  const reader = response.body.getReader();

  let dataChunk;
  do {
    dataChunk = await reader.read();
    if (!dataChunk.done) this.buffer.add(dataChunk.value);
  } while (!dataChunk.done && !this.paused);
}

IndexedDB 的自訂寫入緩衝區

在紙上,將 dataChunk 值寫入 IndexedDB 資料庫的程序很簡單。這些值已是 ArrayBuffer 例項,可直接儲存在 IndexedDB 中,因此我們只要建立適當形狀的物件並儲存即可。

const dataItem = {
  url: fileUrl,
  rangeStart: dataStartByte,
  rangeEnd: dataEndByte,
  data: dataChunk,
}

// Name of the store that will hold your data.
const storeName = 'fileChunksStorage'

// `db` is an instance of `IDBDatabase`.
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const putRequest = store.put(data);

putRequest.onsuccess = () => { ... }

雖然這種方法可行,但您可能會發現 IndexedDB 的寫入速度遠慢於下載速度。這並非因為 IndexedDB 寫入速度緩慢,而是因為我們為從網路接收的每個資料區塊建立新交易,導致交易負荷過大。

下載的區塊可能相當小,且串流可能會快速連續發出這些區塊。您必須限制 IndexedDB 的寫入速率。在 Kino 試用版 PWA 中,我們透過實作中介寫入緩衝區來完成這項操作。

當資料區塊從網路傳輸過來時,我們會先將其附加至緩衝區。如果傳入的資料不符合條件,我們會將整個緩衝區排清至資料庫,並在附加其餘資料前清除緩衝區。因此,IndexedDB 寫入作業的頻率較低,寫入效能也大幅提升。

從離線儲存空間提供媒體檔案

下載媒體檔案後,您可能希望服務工作人員從 IndexedDB 提供檔案,而不是從網路擷取檔案。

/**
 * The main service worker fetch handler.
 *
 * @param {FetchEvent} event Fetch event.
 */
const fetchHandler = async (event) => {
  const getResponse = async () => {
    // Omitted Cache API code used to serve static assets.

    const videoResponse = await getVideoResponse(event);
    if (videoResponse) return videoResponse;

    // Fallback to network.
    return fetch(event.request);
  };
  event.respondWith(getResponse());
};
self.addEventListener('fetch', fetchHandler);

那麼,您需要採取哪些行動?getVideoResponse()

  • event.respondWith() 方法預期 Response 物件做為參數。

  • Response() 建構函式會告訴我們,可用於例項化 Response 物件的物件類型有幾種:BlobBufferSourceReadableStream 等。

  • 我們需要一個不會將所有資料保留在記憶體中的物件,因此可能會選擇 ReadableStream

此外,由於我們處理的是大型檔案,而且希望瀏覽器只要求目前需要的檔案部分,因此我們需要實作 HTTP 範圍要求的基本支援。

/**
 * Respond to a request to fetch offline video file and construct a response
 * stream.
 *
 * Includes support for `Range` requests.
 *
 * @param {Request} request  Request object.
 * @param {Object}  fileMeta File meta object.
 *
 * @returns {Response} Response object.
 */
const getVideoResponse = (request, fileMeta) => {
  const rangeRequest = request.headers.get('range') || '';
  const byteRanges = rangeRequest.match(/bytes=(?<from>[0-9]+)?-(?<to>[0-9]+)?/);

  // Using the optional chaining here to access properties of
  // possibly nullish objects.
  const rangeFrom = Number(byteRanges?.groups?.from || 0);
  const rangeTo = Number(byteRanges?.groups?.to || fileMeta.bytesTotal - 1);

  // Omitting implementation for brevity.
  const streamSource = {
     pull(controller) {
       // Read file data here and call `controller.enqueue`
       // with every retrieved chunk, then `controller.close`
       // once all data is read.
     }
  }
  const stream = new ReadableStream(streamSource);

  // Make sure to set proper headers when supporting range requests.
  const responseOpts = {
    status: rangeRequest ? 206 : 200,
    statusText: rangeRequest ? 'Partial Content' : 'OK',
    headers: {
      'Accept-Ranges': 'bytes',
      'Content-Length': rangeTo - rangeFrom + 1,
    },
  };
  if (rangeRequest) {
    responseOpts.headers['Content-Range'] = `bytes ${rangeFrom}-${rangeTo}/${fileMeta.bytesTotal}`;
  }
  const response = new Response(stream, responseOpts);
  return response;

歡迎查看 Kino 示範 PWA 的服務工作人員原始碼,瞭解我們如何在實際應用程式中從 IndexedDB 讀取檔案資料,並建構串流。

其他注意事項

排除主要障礙後,您現在可以開始為影片應用程式新增一些實用功能。以下列舉幾個您會在 Kino 示範 PWA 中看到的特色:

  • 整合 Media Session API,讓使用者透過專用硬體媒體鍵或媒體通知彈出式視窗,控制媒體播放。
  • 使用舊版 Cache API 快取與媒體檔案相關聯的其他資產,例如字幕和海報圖片。
  • 支援在應用程式內下載影片串流 (DASH、HLS)。由於串流資訊清單通常會宣告多個不同位元率的來源,因此您必須轉換資訊清單檔案,並只下載一個媒體版本,然後儲存以供離線觀看。

接下來,你將瞭解透過預先載入音訊和影片快速播放