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

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

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

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

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

การสร้าง 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 - ซอร์สโค้ด นี้เป็นแบบสาธารณะ ดังนั้นคุณสามารถ ตรวจสอบได้

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

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

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