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