1. 始める前に
プログレッシブ ウェブアプリ(PWA)は、ウェブ経由で配信されるアプリケーション ソフトウェアの一種で、HTML、CSS、JavaScript などの一般的なウェブ技術を使用して構築されています。標準に準拠したブラウザを使用するすべてのプラットフォームで動作するように設計されています。
この Codelab では、ベースラインの PWA から始めて、最終的に PWA にスーパーパワー 🦸 を与える新しいブラウザ機能を試します。
これらの新しいブラウザ機能の多くは開発中で、まだ標準化されていないため、使用するにはブラウザフラグを設定する必要がある場合があります。
前提条件
この Codelab では、最新の JavaScript(特に Promise と async/await)に精通している必要があります。Codelab のすべての手順がすべてのプラットフォームでサポートされているわけではないため、テストに役立つように、Android スマートフォンや、コードを編集するデバイスとは異なるオペレーティング システムを使用するノートパソコンなど、追加のデバイスを手元に用意しておくと便利です。実際のデバイスの代わりに、Android シミュレータなどのシミュレータや、現在のデバイスからテストできる BrowserStack などのオンライン サービスを使用することもできます。また、各ステップは互いに依存していないため、スキップすることもできます。
作成するアプリの概要
グリーティング カードのウェブアプリを作成し、新しいブラウザ機能や今後のブラウザ機能によってアプリを強化し、特定のブラウザで高度なエクスペリエンスを提供できるようにする方法を学びます(すべての最新ブラウザで引き続き有用な状態を維持します)。
ファイル システム アクセス、システム クリップボード アクセス、連絡先の取得、定期的なバックグラウンド同期、画面ウェイクロック、共有機能などのサポート機能を追加する方法を学びます。
この Codelab を完了すると、互換性のないブラウザを使用しているユーザーのサブセットにダウンロードの負担をかけず、最も重要なこととして、そもそもアプリから除外することなく、新しいブラウザ機能でウェブアプリを段階的に強化する方法をしっかりと理解できます。
必要なもの
現時点で完全にサポートされているブラウザは次のとおりです。
特定の Dev チャンネルを使用することをおすすめします。
2. Project Fugu
プログレッシブ ウェブアプリ(PWA)は、最新の API を使用して構築、強化されており、機能、信頼性、インストール性を高めながら、ウェブ上のあらゆるユーザーに、世界中のどこからでも、あらゆる種類のデバイスを使用してリーチできます。
これらの API の一部は非常に強力であり、誤って処理すると問題が発生する可能性があります。フグ🐡のように、正しくカットすれば美味ですが、間違ってカットすると致命的になる可能性があります(ただし、この Codelab では実際に壊れることはありませんのでご安心ください)。
そのため、関係する企業がこれらの新しい API を開発している Web Capabilities プロジェクトの内部コード名は Project Fugu です。
ウェブ機能は、すでに今日、大企業も中小企業も純粋なブラウザベースのソリューションを構築することを可能にしています。多くの場合、プラットフォーム固有のルートをたどるよりも、開発コストを抑えながら迅速なデプロイが可能になります。
3. 始める
いずれかのブラウザをダウンロードし、about://flags
に移動して次のランタイム フラグ 🚩 を設定します。これは Chrome と Edge の両方で機能します。
#enable-experimental-web-platform-features
有効にしたら、ブラウザを再起動します。
PWA をホストでき、エディタも優れているため、プラットフォーム Glitch を使用します。Glitch は GitHub へのインポートとエクスポートもサポートしているため、ベンダー ロックインはありません。fugu-paint.glitch.me にアクセスして、アプリケーションを試してみてください。これは、この Codelab で改善する基本的な描画アプリ 🎨 です。
アプリケーションを試したら、アプリをリミックスして、編集可能な独自のコピーを作成します。リミックスの URL は glitch.com/edit/#!/bouncy-candytuft のようになります(「bouncy-candytuft」は別のものになります)。このリミックスは世界中で直接アクセスできます。作業内容を保存するには、既存のアカウントでログインするか、Glitch で新しいアカウントを作成します。[🕶 Show] ボタンをクリックするとアプリが表示されます。ホストされているアプリの URL は bouncy-candytuft.glitch.me のようになります(トップレベル ドメインは .com
ではなく .me
になります)。
これで、アプリを編集して改善する準備が整いました。アプリは変更を加えるたびに再読み込みされ、変更を直接確認できます。
以下のタスクは、原則として順番に完了する必要がありますが、上記のとおり、対応するデバイスにアクセスできない場合は、いつでも手順をスキップできます。各タスクには、無害な淡水魚の 🐟 または「取り扱い注意」のフグの 🐡 のいずれかのマークが付いています。これにより、機能が試験運用かどうかを判断できます。
DevTools のコンソールで、現在のデバイスで API がサポートされているかどうかを確認します。また、Glitch を使用すると、モバイル デバイスやパソコンなど、さまざまなデバイスで同じアプリを簡単に確認できます。
4. 🐟 Web Share API のサポートを追加
どんなに素晴らしい絵を描いても、それを評価してくれる人がいなければ、退屈なものです。ユーザーがグリーティング カードの形で自分の絵を世界に共有できる機能を追加します。
Web Share API はファイルの共有をサポートしています。また、File
は Blob
の一種です。そのため、share.mjs
というファイルで、共有ボタンと、キャンバスの内容を BLOB に変換する便利な関数 toBlob()
をインポートし、次のコードに従って共有機能を追加します。
この機能を実装してもボタンが表示されない場合は、ブラウザが Web Share API を実装していないことが原因です。
import { shareButton, toBlob } from './script.mjs';
const share = async (title, text, blob) => {
const data = {
files: [
new File([blob], 'fugu-greeting.png', {
type: blob.type,
}),
],
title: title,
text: text,
};
try {
if (!navigator.canShare(data)) {
throw new Error("Can't share data.", data);
}
await navigator.share(data);
} catch (err) {
console.error(err.name, err.message);
}
};
shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});
5. 🐟 Web Share Target API のサポートを追加
これで、ユーザーはアプリを使用して作成したグリーティング カードを共有できるようになりましたが、ユーザーが画像をアプリに共有してグリーティング カードに変換できるようにすることもできます。これには、Web Share Target API を使用できます。
ウェブ アプリケーション マニフェストでは、受け入れ可能なファイルの種類と、1 つまたは複数のファイルが共有されたときにブラウザが呼び出す URL をアプリに伝える必要があります。次の manifest.webmanifest
ファイルの抜粋は、これを示しています。
{
"share_target": {
"action": "./share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "image",
"accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
}
]
}
}
}
サービス ワーカーは、受信したファイルを処理します。URL ./share-target/
は実際には存在しません。アプリは fetch
ハンドラでこの URL を処理し、クエリ パラメータ ?share-target
を追加してリクエストをルート URL にリダイレクトします。
self.addEventListener('fetch', (fetchEvent) => {
/* 🐡 Start Web Share Target */
if (
fetchEvent.request.url.endsWith('/share-target/') &&
fetchEvent.request.method === 'POST'
) {
return fetchEvent.respondWith(
(async () => {
const formData = await fetchEvent.request.formData();
const image = formData.get('image');
const keys = await caches.keys();
const mediaCache = await caches.open(
keys.filter((key) => key.startsWith('media'))[0],
);
await mediaCache.put('shared-image', new Response(image));
return Response.redirect('./?share-target', 303);
})(),
);
}
/* 🐡 End Web Share Target */
/* ... */
});
アプリが読み込まれると、このクエリ パラメータが設定されているかどうかが確認され、設定されている場合は、共有画像がキャンバスに描画され、キャッシュから削除されます。これらはすべて script.mjs
で行われます。
const restoreImageFromShare = async () => {
const mediaCache = await getMediaCache();
const image = await mediaCache.match('shared-image');
if (image) {
const blob = await image.blob();
await drawBlob(blob);
await mediaCache.delete('shared-image');
}
};
この関数は、アプリの初期化時に使用されます。
if (location.search.includes('share-target')) {
restoreImageFromShare();
} else {
drawDefaultImage();
}
6. 🐟 イメージのインポートのサポートを追加
すべてをゼロから描画するのは難しいことです。ユーザーがデバイスからアプリにローカル画像をアップロードできる機能を追加します。
まず、キャンバスの drawImage()
関数についてお読みください。次に、<input
要素について説明します。
type=file>
この知識を活用して、import_image_legacy.mjs
というファイルを編集し、次のスニペットを追加します。インポートするファイルの先頭で、インポート ボタンと、キャンバスに BLOB を描画できる便利な関数 drawBlob()
をインポートします。
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png, image/jpeg, image/*';
input.addEventListener('change', () => {
const file = input.files[0];
input.remove();
return resolve(file);
});
input.click();
});
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
7. 🐟 画像のエクスポートのサポートを追加
アプリで作成されたファイルをデバイスに保存するには、どのような方法がありますか?従来、これは <a
要素で実現されてきました。
download>
ファイル export_image_legacy.mjs
に、次の内容を追加します。エクスポート ボタンと、キャンバスのコンテンツを Blob に変換する toBlob()
便利な関数をインポートします。
import { exportButton, toBlob } from './script.mjs';
export const exportImage = async (blob) => {
const a = document.createElement('a');
a.download = 'fugu-greeting.png';
a.href = URL.createObjectURL(blob);
a.addEventListener('click', (e) => {
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
setTimeout(() => a.click(), 0);
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
exportImage(await toBlob());
});
8. 🐟 File System Access API のサポートを追加
共有は大切ですが、ユーザーは自分の最高の作品を自分のデバイスに保存したいと思うでしょう。ユーザーが図面を保存(して再度開く)ことができる機能を追加します。
以前は、ファイルのインポートに <input type=file>
のレガシー アプローチ、ファイルのエクスポートに <a download>
のレガシー アプローチを使用していました。次に、File System Access API を使用してエクスペリエンスを向上させます。
この API を使用すると、オペレーティング システムのファイル システムからファイルを開いたり、ファイルを保存したりできます。次の内容を追加して、import_image.mjs
と export_image.mjs
の 2 つのファイルをそれぞれ編集します。これらのファイルを読み込むには、script.mjs
から 🐡 絵文字を削除します。
次の行を置き換えます。
// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
/* ... */
}
次のように変更します。
if ('showOpenFilePicker' in window) {
/* ... */
}
import_image.mjs
内:
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
try {
const [handle] = await window.showOpenFilePicker({
types: [
{
description: 'Image files',
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.svg'],
},
},
],
});
return await handle.getFile();
} catch (err) {
console.error(err.name, err.message);
}
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
export_image.mjs
内:
import { exportButton, toBlob } from './script.mjs';
const exportImage = async () => {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'fugu-greetings.png',
types: [
{
description: 'Image file',
accept: {
'image/png': ['.png'],
},
},
],
});
const blob = await toBlob();
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error(err.name, err.message);
}
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
await exportImage();
});
9. 🐟 Contacts Picker API のサポートを追加
ユーザーは、グリーティング カードにメッセージを追加して、個人的な宛先を指定したい場合があります。ユーザーがローカルの連絡先を 1 つ(または複数)選択して、共有メッセージに名前を追加できる機能を追加します。
Android デバイスまたは iOS デバイスでは、連絡先選択ツール API を使用して、デバイスの連絡先マネージャー アプリから連絡先を選択し、アプリケーションに返すことができます。contacts.mjs
ファイルを編集し、次のコードを追加します。
import { contactsButton, ctx, canvas } from './script.mjs';
const getContacts = async () => {
const properties = ['name'];
const options = { multiple: true };
try {
return await navigator.contacts.select(properties, options);
} catch (err) {
console.error(err.name, err.message);
}
};
contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
const contacts = await getContacts();
if (contacts) {
ctx.font = '1em Comic Sans MS';
contacts.forEach((contact, index) => {
ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
});
}
});
10. 🐟 Async Clipboard API のサポートを追加
ユーザーが別のアプリからアプリに画像を貼り付けたり、アプリから別のアプリに図面をコピーしたりしたい場合があります。ユーザーがアプリ内外で画像をコピーして貼り付けることができる機能を追加してください。Async Clipboard API は PNG 画像をサポートしているため、クリップボードで画像データの読み取りと書き込みができるようになりました。
clipboard.mjs
ファイルを見つけて、次の内容を追加します。
import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';
const copy = async (blob) => {
try {
await navigator.clipboard.write([
/* global ClipboardItem */
new ClipboardItem({
[blob.type]: blob,
}),
]);
} catch (err) {
console.error(err.name, err.message);
}
};
const paste = async () => {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
try {
for (const type of clipboardItem.types) {
const blob = await clipboardItem.getType(type);
return blob;
}
} catch (err) {
console.error(err.name, err.message);
}
}
} catch (err) {
console.error(err.name, err.message);
}
};
copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
await copy(await toBlob());
});
pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
const image = new Image();
image.addEventListener('load', () => {
drawImage(image);
});
image.src = URL.createObjectURL(await paste());
});
11. 🐟 Badging API のサポートを追加
ユーザーがアプリをインストールすると、ホーム画面にアイコンが表示されます。このアイコンを使用すると、特定の絵を描くのにかかったストローク数など、楽しい情報を伝えることができます。
ユーザーが新しいブラシストロークを作成するたびにバッジをカウントアップする機能を追加します。バッジ API を使用すると、アプリのアイコンに数値のバッジを設定できます。pointerdown
イベントが発生するたびに(つまり、ブラシストロークが発生するたびに)バッジを更新し、キャンバスがクリアされたときにバッジをリセットできます。
次のコードを badge.mjs
ファイルに配置します。
import { canvas, clearButton } from './script.mjs';
let strokes = 0;
canvas.addEventListener('pointerdown', () => {
navigator.setAppBadge(++strokes);
});
clearButton.addEventListener('click', () => {
strokes = 0;
navigator.setAppBadge(strokes);
});
12. 🐟 Screen Wake Lock API のサポートを追加
ユーザーは、インスピレーションが湧くまで、絵をしばらく見つめる必要がある場合があります。画面をスリープ状態にせず、スクリーン セーバーが起動しないようにする機能を追加します。Screen Wake Lock API は、ユーザーの画面がスリープ状態になるのを防ぎます。ページ表示で定義されている表示状態の変更イベントが発生すると、ウェイクロックは自動的に解除されます。そのため、ページが再び表示されたときにウェイクロックを再取得する必要があります。
wake_lock.mjs
ファイルを見つけて、次の内容を追加します。この機能が動作するかどうかをテストするには、1 分後にスクリーン セーバーが表示されるように設定します。
import { wakeLockInput, wakeLockLabel } from './script.mjs';
let wakeLock = null;
const requestWakeLock = async () => {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released');
});
console.log('Wake Lock is active');
} catch (err) {
console.error(err.name, err.message);
}
};
const handleVisibilityChange = () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
requestWakeLock();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
if (wakeLockInput.checked) {
await requestWakeLock();
} else {
wakeLock.release();
}
});
13. 🐟 Periodic Background Sync API のサポートを追加
白紙のキャンバスから始めるのは退屈なものです。定期的なバックグラウンド同期 API を使用すると、たとえば Unsplash のフグの毎日の写真など、ユーザーのキャンバスを毎日新しい画像で初期化できます。
これには、定期的なバックグラウンド同期を登録するファイル periodic_background_sync.mjs
と、今日の画像のダウンロードを処理する別のファイル image_of_the_day.mjs
の 2 つのファイルが必要です。
periodic_background_sync.mjs
内:
import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';
const getPermission = async () => {
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
return status.state === 'granted';
};
const registerPeriodicBackgroundSync = async () => {
const registration = await navigator.serviceWorker.ready;
try {
registration.periodicSync.register('image-of-the-day-sync', {
// An interval of one day.
minInterval: 24 * 60 * 60 * 1000,
});
} catch (err) {
console.error(err.name, err.message);
}
};
navigator.serviceWorker.addEventListener('message', async (event) => {
const fakeURL = event.data.image;
const mediaCache = await getMediaCache();
const response = await mediaCache.match(fakeURL);
drawBlob(await response.blob());
});
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
if (await getPermission()) {
await registerPeriodicBackgroundSync();
}
const mediaCache = await getMediaCache();
let blob = await mediaCache.match('./assets/background.jpg');
if (!blob) {
blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
}
drawBlob(await blob.blob());
});
image_of_the_day.mjs
内:
const getImageOfTheDay = async () => {
try {
const fishes = ['blowfish', 'pufferfish', 'fugu'];
const fish = fishes[Math.floor(fishes.length * Math.random())];
const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
if (!response.ok) {
throw new Error('Response was', response.status, response.statusText);
}
return await response.blob();
} catch (err) {
console.error(err.name, err.message);
}
};
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
self.addEventListener('periodicsync', (syncEvent) => {
if (syncEvent.tag === 'image-of-the-day-sync') {
syncEvent.waitUntil(
(async () => {
try {
const blob = await getImageOfTheDay();
const mediaCache = await getMediaCache();
const fakeURL = './assets/background.jpg';
await mediaCache.put(fakeURL, new Response(blob));
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
image: fakeURL,
});
});
} catch (err) {
console.error(err.name, err.message);
}
})(),
);
}
});
14. 🐟 Shape Detection API のサポートを追加
ユーザーの描画や使用された背景画像に、バーコードなどの有用な情報が含まれていることがあります。Shape Detection API(特に Barcode Detection API)を使用すると、この情報を抽出できます。ユーザーの描画からバーコードを検出する機能を実装します。barcode.mjs
ファイルを見つけて、次の内容を追加します。この機能をテストするには、バーコードを含む画像をキャンバスに読み込むか貼り付けます。QR コードの画像検索からバーコードの例をコピーできます。
/* global BarcodeDetector */
import {
scanButton,
clearButton,
canvas,
ctx,
CANVAS_BACKGROUND,
CANVAS_COLOR,
floor,
} from './script.mjs';
const barcodeDetector = new BarcodeDetector();
const detectBarcodes = async (canvas) => {
return await barcodeDetector.detect(canvas);
};
scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
const barcodes = await detectBarcodes(canvas);
if (barcodes.length) {
barcodes.forEach((barcode) => {
const rawValue = barcode.rawValue;
if (seenBarcodes.includes(rawValue)) {
return;
}
seenBarcodes.push(rawValue);
ctx.font = '1em Comic Sans MS';
ctx.textAlign = 'center';
ctx.fillStyle = CANVAS_BACKGROUND;
const boundingBox = barcode.boundingBox;
const left = boundingBox.left;
const top = boundingBox.top;
const height = boundingBox.height;
const oneThirdHeight = floor(height / 3);
const width = boundingBox.width;
ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
ctx.fillStyle = CANVAS_COLOR;
ctx.fillText(
rawValue,
left + floor(width / 2),
top + floor(height / 2),
width,
);
});
}
});
15. 🐡 Idle Detection API のサポートを追加
アプリがキオスクのような設定で実行されている場合、一定時間操作がないとキャンバスをリセットする機能が便利です。アイドル状態検出 API を使用すると、ユーザーがデバイスを操作しなくなったタイミングを検出できます。
idle_detection.mjs
ファイルを見つけて、次の内容を貼り付けます。
import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';
let controller;
ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';
ephemeralInput.addEventListener('change', async () => {
if (ephemeralInput.checked) {
const state = await IdleDetector.requestPermission();
if (state !== 'granted') {
ephemeralInput.checked = false;
return alert('Idle detection permission must be granted!');
}
try {
controller = new AbortController();
const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', (e) => {
const { userState, screenState } = e.target;
console.log(`idle change: ${userState}, ${screenState}`);
if (userState === 'idle') {
clearCanvas();
}
});
idleDetector.start({
threshold: 60000,
signal: controller.signal,
});
} catch (err) {
console.error(err.name, err.message);
}
} else {
console.log('Idle detection stopped.');
controller.abort();
}
});
16. 🐡 File Handling API のサポートを追加
ユーザーが画像ファイルをダブルクリックするだけでアプリがポップアップ表示されるとしたらどうでしょうか。File Handling API を使用すると、まさにそれを実現できます。
PWA を画像のファイル ハンドラとして登録する必要があります。これはウェブ アプリケーション マニフェストで行われます。以下の manifest.webmanifest
ファイルの抜粋で、この処理を確認できます。(これはすでにマニフェストに含まれているため、自分で追加する必要はありません)。
{
"file_handlers": [
{
"action": "./",
"accept": {
"image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
}
}
]
}
実際に開いたファイルを処理するには、次のコードを file-handling.mjs
ファイルに追加します。
import { drawBlob } from './script.mjs';
const handleLaunchFiles = () => {
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.files.length) {
return;
}
launchParams.files.forEach(async (handle) => {
const file = await handle.getFile();
drawBlob(file);
});
});
};
handleLaunchFiles();
17. 完了
🎉 お疲れさまでした。
Project Fugu 🐡 のコンテキストで開発されているエキサイティングなブラウザ API が多数あるため、この Codelab ではほんの一部しか取り上げることができませんでした。
詳細については、サイト web.dev で公開されている記事をご覧ください。
しかし、それだけではありません。まだ公開されていない更新については、Fugu API トラッカーで、リリース済み、オリジン トライアルまたはデベロッパー トライアル中のすべての提案、作業が開始されたすべての提案、検討中だがまだ開始されていないすべての提案へのリンクを確認できます。
この Codelab は Thomas Steiner(@tomayac)が作成しました。ご質問がありましたらお気軽にお問い合わせください。フィードバックもお待ちしております。この Codelab の作成にご協力いただいた Hemanth H.M(@GNUmanth)、Christian Liebel(@christianliebel)、Sven May(@Svenmay)、Lars Knudsen(@larsgk)、Jackie Han(@hanguokai)に感謝いたします。