PWA có phát trực tuyến ngoại tuyến

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Ứng dụng web tiến bộ mang lên web rất nhiều tính năng dành riêng cho ứng dụng gốc trước đây. Một trong những tính năng nổi bật nhất liên quan đến PWA là trải nghiệm ngoại tuyến.

Hơn nữa, trải nghiệm phát trực tuyến nội dung đa phương tiện khi không có mạng là một tính năng nâng cao mà bạn có thể cung cấp cho người dùng theo một vài cách khác nhau. Tuy nhiên, việc này tạo ra một vấn đề thực sự khác biệt – các tệp nội dung nghe nhìn có thể rất lớn. Vì vậy, bạn có thể hỏi:

  • Làm cách nào để tải xuống và lưu trữ tệp video lớn?
  • Và làm cách nào để phân phát quảng cáo đến người dùng?

Trong bài viết này, chúng tôi sẽ thảo luận về câu trả lời cho những câu hỏi này, đồng thời tham khảo PWA minh hoạ của Kino mà chúng tôi đã xây dựng, cung cấp cho bạn các ví dụ thiết thực về cách bạn có thể triển khai trải nghiệm phát trực tuyến nội dung nghe nhìn phát trực tuyến ngoại tuyến mà không cần sử dụng bất kỳ khung chức năng hoặc khung trình bày nào. Các ví dụ sau đây chủ yếu dành cho mục đích giáo dục, vì trong hầu hết các trường hợp, bạn có thể nên sử dụng một trong các Khung nội dung nghe nhìn hiện có để cung cấp các tính năng này.

Việc xây dựng một ứng dụng web tiến bộ (PWA) có tính năng phát trực tuyến ngoại tuyến lại có những khó khăn riêng, trừ phi bạn có một trường hợp kinh doanh hiệu quả để phát triển hoạt động kinh doanh của riêng mình. Trong bài viết này, bạn sẽ tìm hiểu về các API và kỹ thuật dùng để mang lại cho người dùng trải nghiệm nội dung nghe nhìn ngoại tuyến chất lượng cao.

Tải xuống và lưu trữ một tệp đa phương tiện có kích thước lớn

Các ứng dụng web tiến bộ thường sử dụng Cache API (API Bộ nhớ đệm) thuận tiện để tải xuống và lưu trữ các tài sản cần thiết nhằm mang lại trải nghiệm ngoại tuyến: tài liệu, bảng định kiểu, hình ảnh và các nội dung khác.

Dưới đây là ví dụ cơ bản về cách sử dụng API Bộ nhớ đệm trong Trình chạy dịch vụ:

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',
      ]);
    })
  );
});

Mặc dù về mặt kỹ thuật, ví dụ trên hoạt động hiệu quả, nhưng việc sử dụng API Bộ nhớ đệm có một số hạn chế khiến việc sử dụng API này với các tệp lớn không thực tế.

Ví dụ: API Bộ nhớ đệm không:

  • Cho phép bạn dễ dàng tạm dừng và tiếp tục tải xuống
  • Cho phép bạn theo dõi tiến trình tải xuống
  • Đưa ra cách phản hồi đúng cách các yêu cầu phạm vi HTTP

Tất cả các vấn đề này đều là những hạn chế khá nghiêm trọng đối với mọi ứng dụng video. Hãy xem xét một số lựa chọn khác có thể phù hợp hơn.

Ngày nay, API Tìm nạp là một cách trên nhiều trình duyệt để truy cập không đồng bộ vào các tệp từ xa. Trong trường hợp sử dụng của chúng tôi, tính năng này cho phép bạn truy cập vào các tệp video có kích thước lớn dưới dạng luồng và lưu trữ chúng dần dần dưới dạng các đoạn thông qua yêu cầu phạm vi HTTP.

Giờ đây, khi đã có thể đọc các phần dữ liệu bằng API Tìm nạp, bạn cũng cần lưu trữ các phần dữ liệu đó. Có thể có một loạt siêu dữ liệu liên kết với tệp nội dung nghe nhìn của bạn, chẳng hạn như tên, nội dung mô tả, thời lượng thời gian chạy, danh mục, v.v.

Bạn không chỉ lưu trữ một tệp nội dung nghe nhìn, mà đang lưu trữ một đối tượng có cấu trúc và tệp đa phương tiện chỉ là một trong các thuộc tính của tệp đó.

Trong trường hợp này, IndexedDB API cung cấp một giải pháp hiệu quả để lưu trữ cả dữ liệu phương tiện và siêu dữ liệu. Lớp này có thể dễ dàng chứa lượng lớn dữ liệu nhị phân và cung cấp các chỉ mục cho phép bạn thực hiện tra cứu dữ liệu rất nhanh.

Tải các tệp nội dung nghe nhìn xuống bằng API Tìm nạp

Chúng tôi đã xây dựng một số tính năng thú vị xoay quanh API Tìm nạp trong PWA minh hoạ mà chúng tôi đặt tên là Kinomã nguồn là công khai, vì vậy, vui lòng xem lại mã này.

  • Có thể tạm dừng và tiếp tục quá trình tải xuống chưa hoàn tất.
  • Một vùng đệm tuỳ chỉnh để lưu trữ các phần dữ liệu trong cơ sở dữ liệu.

Trước khi cho thấy cách triển khai các tính năng đó, trước tiên, chúng tôi sẽ tóm tắt nhanh về cách bạn có thể sử dụng API Tìm nạp để tải tệp xuống.

/**
 * 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);
}

Bạn có nhận thấy await reader.read() ở trong một vòng lặp không? Đó là cách bạn sẽ nhận được các đoạn dữ liệu từ một luồng có thể đọc được khi chúng đến từ mạng. Hãy cân nhắc mức độ hữu ích của việc này: bạn có thể bắt đầu xử lý dữ liệu của mình ngay cả trước khi dữ liệu đến từ mạng.

Đang tiếp tục tải xuống

Khi quá trình tải xuống bị tạm dừng hoặc gián đoạn, các phần dữ liệu đã đến sẽ được lưu trữ an toàn trong cơ sở dữ liệu IndexedDB. Sau đó, bạn có thể hiển thị một nút để tiếp tục tải xuống trong ứng dụng của mình. Vì máy chủ PWA minh hoạ của Kino hỗ trợ các yêu cầu phạm vi HTTP, việc tiếp tục tải xuống quá trình tải xuống khá đơn giản:

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);
}

Vùng đệm ghi tuỳ chỉnh cho IndexedDB

Trên giấy, quá trình ghi giá trị dataChunk vào cơ sở dữ liệu IndexedDB rất đơn giản. Các giá trị đó đã là thực thể ArrayBuffer, có thể lưu trữ trực tiếp trong IndexedDB, vì vậy, chúng ta chỉ cần tạo một đối tượng có hình dạng phù hợp và lưu trữ đối tượng đó.

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 = () => { ... }

Mặc dù phương pháp này có tác dụng, nhưng có thể bạn sẽ phát hiện ra rằng lượt ghi IndexedDB chậm hơn đáng kể so với quá trình tải xuống. Điều này không phải là do quá trình ghi IndexedDB bị chậm mà là do chúng ta đang làm tăng thêm rất nhiều chi phí giao dịch thông qua việc tạo một giao dịch mới cho mỗi đoạn dữ liệu nhận được từ một mạng.

Các phân đoạn được tải xuống có thể khá nhỏ và có thể được luồng phát ra liên tiếp nhanh chóng. Bạn cần giới hạn tốc độ ghi IndexedDB. Trong PWA minh hoạ Kino, chúng tôi thực hiện việc này bằng cách triển khai một vùng đệm ghi trung gian.

Khi các phần dữ liệu đến từ mạng, trước tiên chúng ta sẽ thêm chúng vào vùng đệm của mình. Nếu dữ liệu đến không phù hợp, chúng ta sẽ đẩy toàn bộ vùng đệm vào cơ sở dữ liệu và xoá trước khi thêm phần dữ liệu còn lại. Do đó, việc ghi IndexedDB của chúng tôi ít thường xuyên hơn, dẫn đến hiệu suất ghi được cải thiện đáng kể.

Cung cấp tệp đa phương tiện từ bộ nhớ ngoại tuyến

Sau khi tải tệp nội dung nghe nhìn xuống, bạn có thể muốn trình chạy dịch vụ của mình phân phát tệp đó từ IndexedDB thay vì tìm nạp tệp từ mạng.

/**
 * 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);

Vậy bạn cần làm gì ở getVideoResponse()?

  • Phương thức event.respondWith() yêu cầu đối tượng Response làm tham số.

  • Hàm khởi tạo phản hồi() cho chúng ta biết rằng có một số loại đối tượng mà chúng ta có thể sử dụng để tạo thực thể cho đối tượng Response: Blob, BufferSource, ReadableStream và nhiều loại đối tượng khác.

  • Chúng ta cần một đối tượng không lưu giữ tất cả dữ liệu của lớp đó trong bộ nhớ, vì vậy có thể chúng ta sẽ cần chọn ReadableStream.

Ngoài ra, do chúng tôi đang xử lý các tệp có kích thước lớn và muốn cho phép các trình duyệt chỉ yêu cầu phần tệp mà họ hiện cần, nên chúng tôi cần triển khai một số hoạt động hỗ trợ cơ bản cho các yêu cầu về phạm vi 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;

Vui lòng xem mã nguồn trình chạy dịch vụ của PWA Kino để tìm hiểu cách chúng ta đọc dữ liệu tệp từ IndexedDB và xây dựng luồng trong ứng dụng thực.

Lưu ý khác

Loại bỏ các trở ngại chính, giờ đây, bạn có thể bắt đầu thêm một số tính năng cần có vào ứng dụng video của mình. Dưới đây là một vài ví dụ về các tính năng bạn sẽ thấy trong bản minh hoạ PWA Kino:

  • Tích hợp Media Session API (API Phiên phương tiện) cho phép người dùng kiểm soát việc phát nội dung nghe nhìn bằng cách sử dụng các khoá phương tiện phần cứng chuyên dụng hoặc từ cửa sổ bật lên thông báo về nội dung nghe nhìn.
  • Lưu vào bộ nhớ đệm các nội dung khác liên kết với tệp nội dung nghe nhìn như phụ đề và hình ảnh áp phích bằng API bộ nhớ đệm cũ.
  • Hỗ trợ tải xuống luồng video (DASH, HLS) trong ứng dụng. Vì tệp kê khai luồng thường khai báo nhiều nguồn có tốc độ bit khác nhau, bạn cần chuyển đổi tệp kê khai và chỉ tải một phiên bản nội dung nghe nhìn xuống trước khi lưu trữ để xem ngoại tuyến.

Tiếp theo, bạn sẽ tìm hiểu về tính năng Phát nhanh bằng tính năng tải trước âm thanh và video.