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ộ đưa nhiều tính năng trước đây dành riêng cho các ứng dụng gốc lên web. 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.

Trải nghiệm phát trực tuyến nội dung nghe nhìn ngoại tuyến còn tốt hơn nữa. Đây 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 số cách. Tuy nhiên, điều này tạo ra một vấn đề thực sự có một không hai, đó là các tệp nội dung nghe nhìn có thể có kích thước rất lớn. Vậy nên, có thể bạn sẽ 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ối quảng cáo đó đến người dùng?

Trong bài viết này, chúng ta sẽ thảo luận câu trả lời cho những câu hỏi này, đồng thời đề cập đến PWA Kino minh hoạ do chúng tôi tạo để 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 ngoại tuyến mà không cần sử dụng bất kỳ khung chức năng hoặc 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 nên sử dụng một trong các Khung nội dung đa phương tiện hiện có để cung cấp các tính năng này.

Trừ phi bạn có một trường hợp kinh doanh phù hợp để phát triển ứng dụng của riêng mình, 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 khi không có mạng sẽ gặp nhiều khó khăn riêng. Trong bài viết này, bạn sẽ tìm hiểu về các API và kỹ thuật dùng để cung cấp 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ữ tệp đa phương tiện có kích thước lớn

Ứng dụng web tiến bộ thường sử dụng 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 để 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 tài sản 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í dụ trên hoạt động về mặt kỹ thuật, 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:

  • Giú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 đối với các yêu cầu về phạm vi HTTP

Tất cả những vấn đề này là những hạn chế khá nghiêm trọng đối với bất kỳ ứng dụng video nào. Hãy xem xét một số phương á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 ta, tính năng này cho phép bạn truy cập các tệp video lớn dưới dạng luồng và lưu trữ tăng dần dưới dạng phân đoạn bằng cách sử dụng yêu cầu phạm vi HTTP.

Giờ đây, bạn 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 được 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à bạn đang lưu trữ đối tượng có cấu trúc và tệp nội dung nghe nhìn chỉ là một trong các thuộc tính của đối tượng đó.

Trong trường hợp này, IndexedDB API cung cấp một giải pháp tuyệt vời để lưu trữ cả dữ liệu nội dung nghe nhìn và siêu dữ liệu. Nền tảng này có thể dễ dàng lưu trữ một lượng lớn dữ liệu nhị phân, đồng thời cung cấp các chỉ mục cho phép bạn thực hiện các thao tác tra cứu dữ liệu ở tốc độ rất nhanh.

Tải tệp đa phương tiệ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ị liên quan đến API Tìm nạp trong PWA minh hoạ, chúng tôi đặt tên là Kino. Mã nguồn này là công khai nên bạn có thể tham khảo các tính năng 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.
  • 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 trình bày cách triển khai các tính năng đó, đầu tiên chúng tôi sẽ tóm tắt nhanh 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 nhận thấy await reader.read() có trong vòng lặp không? Đó là cách bạn sẽ nhận được các phầ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 tính hữu ích của phương pháp 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 bị gián đoạn, các đoạ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ị nút để tiếp tục tải xuống trong ứng dụng của mình. Vì máy chủ PWA Kino minh hoạ hỗ trợ các yêu cầu phạm vi HTTP tiếp tục tải xuống có phần đơ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 tờ, 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 thích 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ó hiệu quả, nhưng bạn có thể sẽ nhận thấy rằng quá trình ghi IndexedDB chậm hơn đáng kể so với tốc độ tải xuống. Điều này không phải do IndexedDB ghi có tốc độ chậm, mà là do chúng tôi đang tăng thêm nhiều chi phí giao dịch bằng cách tạo một giao dịch mới cho mọi đoạn dữ liệu mà chúng tôi nhận được từ mạng.

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

Khi các đoạn dữ liệu đến từ mạng, trước tiên, chúng ta sẽ thêm các đoạn dữ liệu đó vào vùng đệm. Nếu dữ liệu đầu vào không phù hợp, chúng tôi sẽ đẩy toàn bộ bộ đệm vào cơ sở dữ liệu và xoá dữ liệu đó 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ể.

Phân phối 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ụ 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ì trong getVideoResponse()?

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

  • Hàm khởi tạoResponse() cho chúng ta biết rằng có một số loại đối tượng chúng ta có thể dùng để tạo thực thể cho đối tượng Response: Blob, BufferSource, ReadableStream, v.v.

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

Ngoài ra, do chúng tôi đang xử lý các tệp lớn và chúng tôi muốn cho phép các trình duyệt chỉ yêu cầu một phần của tệp mà họ hiện cần, nên chúng tôi cần triển khai một số tính năng hỗ trợ cơ bản cho các yêu cầu 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 bản minh hoạ Kino mã nguồn trình chạy dịch vụ của PWA để tìm hiểu cách chúng tôi đọc dữ liệu tệp từ IndexedDB và tạo luồng trong ứng dụng thực.

Lưu ý khác

Sau khi vượt qua các trở ngại lớn, giờ đây, bạn có thể bắt đầu thêm một số tính năng hữu ích 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 có trong PWA Kino minh hoạ:

  • Tính năng tích hợp API phiên nội dung nghe nhìn cho phép người dùng điều khiển việc phát nội dung nghe nhìn bằng cách sử dụng các phím nội dung nghe nhìn phần cứng chuyên dụng hoặc từ cửa sổ bật lên thông báo nội dung nghe nhìn.
  • Lưu vào bộ nhớ đệm các tài sản khác liên kết với các 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 luồng video (DASH, HLS) xuống trong ứng dụng. Vì các tệp kê khai luồng thường khai báo nhiều nguồn với tốc độ bit khác nhau, nên bạn cần biế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 khi không có mạng.

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