PWA พร้อมการสตรีมแบบออฟไลน์

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Progressive Web App มีฟีเจอร์มากมายที่เคยสงวนไว้สำหรับแอปพลิเคชันที่มาพร้อมเครื่องบนเว็บ หนึ่งในฟีเจอร์ที่โดดเด่นที่สุดของ PWA คือการใช้งานแบบออฟไลน์

แต่ถ้าให้ดียิ่งกว่านั้นก็คือประสบการณ์สื่อสตรีมมิงแบบออฟไลน์ ซึ่งช่วยให้คุณสามารถนำเสนอการเพิ่มประสิทธิภาพแก่ผู้ใช้ได้หลายวิธี แต่วิธีนี้จะสร้างปัญหาที่ไม่เหมือนใครอย่างแท้จริง นั่นคือไฟล์สื่ออาจมีขนาดใหญ่มาก คุณอาจสงสัยว่า

  • ฉันจะดาวน์โหลดและเก็บไฟล์วิดีโอขนาดใหญ่ได้อย่างไร
  • และฉันจะแสดงต่อผู้ใช้ได้อย่างไร

ในบทความนี้เราจะพูดถึงคำตอบสำหรับคำถามเหล่านี้ ในขณะที่อ้างอิง PWA เวอร์ชันสาธิตของ Kino ที่เราสร้างขึ้นเพื่อแสดงตัวอย่างที่ใช้ได้จริงเกี่ยวกับวิธีนำประสบการณ์สื่อสตรีมมิงแบบออฟไลน์ไปใช้โดยไม่ต้องใช้เฟรมเวิร์กการทำงานหรือการนำเสนอ ตัวอย่างต่อไปนี้มีไว้เพื่อวัตถุประสงค์ด้านการศึกษาเป็นหลัก เนื่องจากในกรณีส่วนใหญ่ คุณควรใช้หนึ่งในเฟรมเวิร์กสื่อที่มีอยู่เพื่อให้บริการฟีเจอร์เหล่านี้

การสร้าง PWA ด้วยสตรีมมิงแบบออฟไลน์เป็นเรื่องที่ท้าทาย เว้นแต่คุณจะมีเหตุผลทางธุรกิจที่ดีในการพัฒนา PWA ในบทความนี้ คุณจะได้เรียนรู้เกี่ยวกับ API และเทคนิคที่ใช้ในการมอบประสบการณ์สื่อออฟไลน์คุณภาพสูงให้แก่ผู้ใช้

การดาวน์โหลดและจัดเก็บไฟล์สื่อขนาดใหญ่

Progressive Web App มักใช้ Cache API เพื่อความสะดวกในการดาวน์โหลดและจัดเก็บเนื้อหาที่จำเป็นต่อการมอบประสบการณ์การใช้งานแบบออฟไลน์ เช่น เอกสาร สไตล์ชีต รูปภาพ และอื่นๆ

ต่อไปนี้คือตัวอย่างพื้นฐานของการใช้ Cache API ภายใน Service Worker

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 อย่างเหมาะสม

ปัญหาทั้งหมดเหล่านี้เป็นข้อจำกัดที่ร้ายแรงสำหรับแอปพลิเคชันวิดีโอต่างๆ มาทบทวนตัวเลือกอื่นๆ ที่อาจเหมาะสมกว่า

ปัจจุบัน API การดึงข้อมูลเป็นวิธีเข้าถึงไฟล์ระยะไกลแบบไม่พร้อมกันในหลายๆ เบราว์เซอร์ ในกรณีการใช้งานของเรา เครื่องมือนี้ช่วยให้คุณเข้าถึงไฟล์วิดีโอขนาดใหญ่เป็นสตรีมและจัดเก็บไฟล์เหล่านั้นแบบเป็นส่วนๆ ทีละส่วนโดยใช้คำขอช่วง HTTP ได้

ตอนนี้คุณสามารถอ่านข้อมูลจำนวนมากด้วย Fetch API แล้ว คุณยังต้องจัดเก็บข้อมูลเหล่านั้นด้วย เป็นไปได้ที่จะมีข้อมูลเมตาจำนวนมากที่เชื่อมโยงกับไฟล์สื่อของคุณ เช่น ชื่อ คำอธิบาย ความยาวรันไทม์ หมวดหมู่ ฯลฯ

คุณไม่ได้จัดเก็บไฟล์สื่อเพียงไฟล์เดียว แต่กำลังจัดเก็บออบเจ็กต์ที่มีโครงสร้าง และไฟล์สื่อเป็นเพียงพร็อพเพอร์ตี้หนึ่ง

ในกรณีนี้ IndexedDB API เป็นโซลูชันที่ยอดเยี่ยมสำหรับจัดเก็บทั้งข้อมูลสื่อและข้อมูลเมตา ซึ่งสามารถเก็บข้อมูลไบนารีจำนวนมากได้อย่างง่ายดาย และ ยังมีดัชนีที่ช่วยให้คุณค้นหาข้อมูลได้อย่างรวดเร็ว

การดาวน์โหลดไฟล์สื่อโดยใช้ API การดึงข้อมูล

เราได้สร้างฟีเจอร์ที่น่าสนใจบางอย่างโดยใช้ Fetch API ใน PWA เดโมของเรา ซึ่งเราตั้งชื่อว่า Kinoซอร์สโค้ดเป็นโค้ดสาธารณะ ดังนั้นโปรดตรวจสอบโค้ดนี้

  • ความสามารถในการหยุดการดาวน์โหลดที่ไม่สมบูรณ์ชั่วคราวและทำต่อ
  • บัฟเฟอร์ที่กำหนดเองสำหรับจัดเก็บข้อมูลจำนวนมากในฐานข้อมูล

ก่อนที่จะแสดงวิธีการนำคุณลักษณะเหล่านั้นไปใช้ ก่อนอื่นเราจะดูข้อมูลสรุปคร่าวๆ เกี่ยวกับวิธีที่คุณสามารถใช้ 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 จากนั้น คุณสามารถแสดงปุ่ม ให้ดาวน์โหลดแอปพลิเคชันต่อ เนื่องจากเซิร์ฟเวอร์ PWA เดโมของ Kino รองรับคำขอช่วง 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 ใน PWA เดโมของ Kino นั้นดำเนินการโดยใช้บัฟเฟอร์การเขียนที่เป็นสื่อกลาง

เมื่อกลุ่มข้อมูลมาจากเครือข่าย เราจะนำไปต่อท้ายบัฟเฟอร์ของเราก่อน หากข้อมูลที่เข้ามาใหม่ไม่พอดี เราจะล้างบัฟเฟอร์ทั้งหมดลงในฐานข้อมูลและล้างก่อนที่จะนำข้อมูลที่เหลือมาต่อท้าย ผลที่ตามมาคือการเขียน IndexedDB ของเราไม่บ่อยนัก ทำให้ประสิทธิภาพการเขียนดีขึ้นอย่างมาก

การแสดงไฟล์สื่อจากพื้นที่เก็บข้อมูลออฟไลน์

เมื่อดาวน์โหลดไฟล์สื่อแล้ว คุณอาจต้องการให้ Service Worker แสดงไฟล์นั้นจาก 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 ได้ เช่น Blob, BufferSource,ReadableStream และอื่นๆ

  • เราต้องการออบเจ็กต์ที่ไม่ได้เก็บข้อมูลทั้งหมดของออบเจ็กต์ไว้ในหน่วยความจำ เราจึงน่าจะเลือก 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;

ดูตัวอย่างซอร์สโค้ดโปรแกรมทำงานของบริการ PWA ของ Kino เพื่อดูวิธีอ่านข้อมูลไฟล์จาก IndexedDB และสร้างสตรีมในแอปพลิเคชันจริง

ข้อควรพิจารณาอื่นๆ

เมื่อมีอุปสรรคหลักๆ ที่ขวางหน้า ตอนนี้คุณก็เริ่มเพิ่ม ฟีเจอร์ดีๆ ที่ใครๆ ก็ใช้ได้ลงในแอปพลิเคชันวิดีโอได้แล้ว ต่อไปนี้คือตัวอย่างฟีเจอร์บางส่วนที่คุณจะพบใน PWA แบบสาธิตของ Kino

  • การผสานรวม Media Session API ที่ช่วยให้ผู้ใช้ควบคุมการเล่นสื่อโดยใช้คีย์สื่อฮาร์ดแวร์โดยเฉพาะหรือจากป๊อปอัปการแจ้งเตือนสื่อ
  • การแคชเนื้อหาอื่นๆ ที่เกี่ยวข้องกับไฟล์สื่อ เช่น คำบรรยายและโปสเตอร์โดยใช้ Cache API เวอร์ชันเก่า
  • รองรับการดาวน์โหลดสตรีมวิดีโอ (DASH, HLS) ภายในแอป เนื่องจากโดยปกติแล้วไฟล์ Manifest ของสตรีมจะประกาศแหล่งที่มาของอัตราบิตที่แตกต่างกันหลายแหล่ง คุณจึงต้องแปลงไฟล์ Manifest และดาวน์โหลดสื่อเพียงเวอร์ชันเดียวก่อนที่จะจัดเก็บไว้สำหรับการดูแบบออฟไลน์

ต่อไปคุณจะได้เรียนรู้เกี่ยวกับการเล่นอย่างรวดเร็วด้วยเสียงและวิดีโอที่โหลดล่วงหน้า