Excalidraw 및 Fugu: 핵심 사용자 여정 개선

아무리 진보된 기술도 마법과 구별할 수 없습니다. 당신이 이해하지 않는 한. 저는 Google의 개발자 관계팀에서 일하는 토마스 스타이너입니다. 이번 Google I/O 강연 게시물에서는 몇 가지 새로운 Fugu API를 살펴보고 Excalidraw PWA의 핵심 사용자 여정을 개선하는 방법을 살펴보겠습니다. 이렇게 하면 아이디어에서 영감을 얻어 자신의 앱에 적용해 볼 수 있습니다.

내가 엑스칼리드로를 처음 접하게 된 계기

이야기로 시작하고 싶어요. 2020년 1월 1일에 Christopher Chedeau, Facebook의 소프트웨어 엔지니어 가지고 있는 작은 그리기 앱에 대해 트윗 연구하기 시작했습니다. 이 도구를 사용하면 만화처럼 느껴지는 상자와 화살표를 그릴 수 있습니다. 수작업으로 그린 그림입니다. 다음 날에는 타원과 텍스트를 그리고 객체를 선택하고 있습니다. 1월 3일에 이 앱의 이름은 엑스칼리드로였습니다. 도메인 이름 구입은 크리스토퍼의 첫 번째 행위 중 하나였습니다. 작성자: 이제 색상을 사용하고 그림 전체를 PNG로 내보낼 수 있습니다.

직사각형, 화살표, 타원, 텍스트를 지원함을 보여주는 Excalidraw 프로토타입 애플리케이션의 스크린샷

1월 15일 크리스토퍼는 블로그 게시물을 올려 많은 관심을 받았습니다. 이 게시물은 인상적인 통계로 시작되었습니다.

  • 순 활성 사용자 12,000명
  • GitHub 별표 1,500개
  • 참여자 26명

불과 2주 전에 시작한 프로젝트의 경우, 그리 나쁘지 않습니다. 하지만 정말로 중요한 것은 게시물에서 관심이 급등했습니다. 크리스토퍼는 자신이 해볼 수 있는 곳을 통해 시간: pull 요청을 완료한 모든 사용자에게 무조건 커밋 액세스 권한을 부여합니다. 당일 블로그 게시물을 읽다가 가져오기 요청이 Excalidraw에 File System Access API 지원을 추가하여 기능 요청이 있습니다.

PR을 발표하는 트윗의 스크린샷

내 pull 요청이 하루 후 병합되었고 그 이후 전체 커밋 액세스 권한을 갖게 되었습니다. 말할 필요도 없지만, 내 힘을 악용하지 않았어. 그리고 지금까지 도움을 주신 분들 149명 중에 누구도 없었습니다.

현재 Excalidraw는 정식 설치 가능한 프로그레시브 웹 앱으로, 오프라인 지원, 놀라운 어두운 모드, 그리고 다양한 기능을 통해 파일 열기 및 저장과 같은 File System Access API

현재 상태의 Excalidraw PWA 스크린샷.

리피스가 엑스칼리드로에 그렇게 많은 시간을 할애하는 이유

이렇게 해서 '내가 Excalidraw에 도착한 방법'이 끝났습니다 하지만 본론으로 들어가서 Excalidraw의 놀라운 기능을 소개하게 되어 기쁩니다. 파나이오티스 리피리디스, lipis라고 하는 인터넷은 인터넷의 발전에 가장 크게 기여하는 엑스칼리드로 나는 립피스에게 그가 엑스칼리드로에 그렇게 많은 시간을 바칠 수 있게 된 동기가 무엇인지 물어봤다.

다른 사람들처럼 저도 크리스토퍼의 트윗을 통해 이 프로젝트에 대해 알게 되었습니다. 첫 참여 기본 색상인 Open Color 라이브러리를 추가하는 것이었습니다. 오늘 Excalidraw의 일부입니다. 프로젝트가 커지고 요청이 많아짐에 따라 사용자가 그림을 공유할 수 있도록 그림을 저장할 수 있는 백엔드를 구축하는 데 기여했습니다. 하지만 엑스칼리드로를 시도했던 사람은 누구나 그 답을 찾을 수 있는 구실을 찾게 되고 다시 실행합니다.

Lipis에 대해 완전히 동의합니다. 누구든 엑스칼리드로를 다시 사용할 구실을 찾고 있는 것입니다.

Excalidraw 실행

이제 엑스칼리드로를 실제로 사용하는 방법을 보여드리겠습니다. 제가 위대한 예술가는 아니지만 Google I/O 로고는 간단하므로 한 번 해 보겠습니다. 상자는 'i'이고 선은 슬래시 및 'o' 은(는) 원입니다. Shift를 누르면 완벽한 원이 그려집니다. 움직이기 더 잘 보이게 할 수 있습니다. 이제 'i'의 색깔이 그리고 'o'가 있습니다. 파란색이 좋습니다. 미정 어떻게 해야 할까요? 모두 채워져 있는가, 아니면 크로스 해치? 아니, 하슈레는 멋지다. 완벽하진 않지만 엑스칼리드로의 개념입니다. 저장하겠습니다.

저장 아이콘을 클릭하고 파일 저장 대화상자에 파일 이름을 입력합니다. Chrome에서 File System Access API를 지원하지만 다운로드가 아니라 진정한 저장 작업이므로 파일의 위치와 이름을 선택한 다음, 편집한 내용을 동일한 파일을 찾습니다.

로고를 변경하고 'i'를 만들어 보겠습니다. 있습니다. 이제 저장을 다시 클릭하면 수정사항이 이전과 동일한 파일로 복사됩니다 먼저 캔버스를 지우고 파일을 다시 열어 보겠습니다. 보시다시피 수정된 빨간색 파란색 로고가 다시 나타납니다.

파일 작업

현재 File System Access API를 지원하지 않는 브라우저에서 각 저장 작업은 파일을 변경할 때 파일의 숫자가 늘어났고 다운로드 폴더를 채우는 파일 이름입니다. 하지만 이러한 단점에도 불구하고 여전히 파일을 저장할 수 있습니다.

파일 열기

비결이 뭐죠? 다른 브라우저에서 열고 저장하는 방법 File System Access API를 지원하나요? Excalidraw에서 파일을 여는 것은 loadFromJSON)()을 사용하여 fileOpen()이라는 함수를 호출합니다.

export const loadFromJSON = async (localAppState: AppState) => {
  const blob = await fileOpen({
    description: 'Excalidraw files',
    extensions: ['.json', '.excalidraw', '.png', '.svg'],
    mimeTypes: ['application/json', 'image/png', 'image/svg+xml'],
  });
  return loadFromBlob(blob, localAppState);
};

내가 작성한 작은 라이브러리에서 가져온 fileOpen() 함수 Google이 사용하는 browser-fs-access 엑스칼리드로 이 라이브러리는 File System Access API(레거시 대체 포함)가 있어 모든 기기에서 사용 가능 있습니다.

먼저 API가 지원되는 경우의 구현을 보여 드리겠습니다. 협의를 통해 허용되는 MIME 유형 및 파일 확장자에 대해, 중심은 File System Access API의 함수 showOpenFilePicker()). 이 함수는 주어진 함수에 종속된 파일 배열 또는 단일 파일을 반환합니다. 선택할 수 있습니다. 이제 파일 핸들을 파일에 놓기만 하면 됩니다. 객체를 다시 가져올 수 있습니다.

export default async (options = {}) => {
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  const handleOrHandles = await window.showOpenFilePicker({
    types: [
      {
        description: options.description || '',
        accept: accept,
      },
    ],
    multiple: options.multiple || false,
  });
  const files = await Promise.all(handleOrHandles.map(getFileWithHandle));
  if (options.multiple) return files;
  return files[0];
  const getFileWithHandle = async (handle) => {
    const file = await handle.getFile();
    file.handle = handle;
    return file;
  };
};

대체 구현에서는 "file" 유형의 input 요소를 사용합니다. 협상을 통해 허용될 MIME 유형 및 확장자에 관해 알아보았다면 다음 단계는 프로그래매틱 방식으로 입력 요소를 사용하여 파일 열기 대화상자가 표시되도록 합니다. 변경 시, 즉 사용자가 여러 파일이 있는 경우 프로미스가 해결됩니다.

export default async (options = {}) => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    const accept = [
      ...(options.mimeTypes ? options.mimeTypes : []),
      options.extensions ? options.extensions : [],
    ].join();
    input.multiple = options.multiple || false;
    input.accept = accept || '*/*';
    input.addEventListener('change', () => {
      resolve(input.multiple ? Array.from(input.files) : input.files[0]);
    });
    input.click();
  });
};

파일 저장 중

저장 중입니다. Excalidraw에서는 saveAsJSON()이라는 함수에서 저장합니다. 먼저 Excalidraw 요소 배열을 JSON으로 직렬화하고 JSON을 blob으로 변환한 후 fileSave() 함수를 사용합니다. 마찬가지로 이 함수는 browser-fs-access 라이브러리를 사용합니다.

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: 'application/vnd.excalidraw+json',
  });
  const fileHandle = await fileSave(
    blob,
    {
      fileName: appState.name,
      description: 'Excalidraw file',
      extensions: ['.excalidraw'],
    },
    appState.fileHandle,
  );
  return { fileHandle };
};

File System Access API를 지원하는 브라우저의 구현 방법을 먼저 살펴보겠습니다. 이 처음 몇 줄은 약간 복잡해 보이지만 MIME 유형과 파일을 협상하기만 하면 됩니다. 확장 프로그램 이전에 저장했고 이미 파일 핸들이 있으면 저장 대화상자를 있습니다. 하지만 첫 번째 저장인 경우 파일 대화상자가 표시되고 앱에서 파일 핸들을 가져옵니다. 나중에 사용할 수 있도록 되돌립니다. 그런 다음 나머지는 파일에 쓰기만 하고 쓰기 가능 스트림을 사용합니다.

export default async (blob, options = {}, handle = null) => {
  options.fileName = options.fileName || 'Untitled';
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  handle =
    handle ||
    (await window.showSaveFilePicker({
      suggestedName: options.fileName,
      types: [
        {
          description: options.description || '',
          accept: accept,
        },
      ],
    }));
  const writable = await handle.createWritable();
  await writable.write(blob);
  await writable.close();
  return handle;
};

'다른 이름으로 저장' 특성

이미 존재하는 파일 핸들을 무시하기로 결정한 경우 '다른 이름으로 저장'을 구현할 수 있습니다. 특성을 기존 파일을 기반으로 새 파일을 만듭니다 보여드리기 위해 기존 파일을 열고 기존 파일을 덮어쓰지 않고 다른 이름으로 새 파일을 만들어 기능을 사용할 수 있습니다. 이렇게 하면 원본 파일이 그대로 유지됩니다.

File System Access API를 지원하지 않는 브라우저를 위한 구현은 원하는 파일 이름인 download 속성을 사용하여 앵커 요소를 만들고 blob URL을 href 속성값으로 사용합니다.

export default async (blob, options = {}) => {
  const a = document.createElement('a');
  a.download = options.fileName || 'Untitled';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', () => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

그러면 앵커 요소가 프로그래매틱 방식으로 클릭됩니다. 메모리 누수를 방지하려면 blob URL에 취소될 수 있습니다. 다운로드일 뿐이므로 파일 저장 대화상자가 표시되지 않으며 파일이 기본 Downloads 폴더에 배치됩니다.

드래그 앤 드롭

데스크톱에서 제가 제일 좋아하는 시스템 통합 중 하나는 드래그 앤 드롭입니다. Excalidraw에서 .excalidraw 파일을 애플리케이션에 복사하면 즉시 열리고 수정을 시작할 수 있습니다. 브라우저에서 File System Access API를 지원하는 경우 변경사항을 즉시 저장할 수도 있습니다. 이동하지 않아도 됩니다. 드래그 앤 드롭에서 필요한 파일 핸들을 얻었기 때문에 파일 저장 대화상자를 통해 연산으로 해석됩니다.

이렇게 하는 비결은 getAsFileSystemHandle() 데이터 전송 항목(File System Access API가 지원되는 경우) 그런 다음 이 파일 핸들을 loadFromBlob()에 추가합니다. 위의 몇 단락에서 확인할 수 있습니다. 너무 많음 열기, 저장, 초과 저장, 드래그, 드롭 등 파일에서 할 수 있는 작업을 보여줍니다. 내 동료인 피트 이 모든 유용한 정보와 자세한 내용은 도움말에 나와 있으니 참고하시기 바랍니다. 이 모든 것이 너무 빨리 진행되었을 수도 있으니 따라잡으세요.

const file = event.dataTransfer?.files[0];
if (file?.type === 'application/json' || file?.name.endsWith('.excalidraw')) {
  this.setState({ isLoading: true });
  // Provided by browser-fs-access.
  if (supported) {
    try {
      const item = event.dataTransfer.items[0];
      file as any.handle = await item as any
        .getAsFileSystemHandle();
    } catch (error) {
      console.warn(error.name, error.message);
    }
  }
  loadFromBlob(file, this.state).then(({ elements, appState }) =>
    // Load from blob
  ).catch((error) => {
    this.setState({ isLoading: false, errorMessage: error.message });
  });
}

파일 공유

현재 Android, ChromeOS, Windows에서 실행되는 또 다른 시스템은 Web Share Target API. 여기 Downloads 폴더의 Files 앱이 있습니다. 난 두 개의 파일을 볼 수 있습니다. 그중 하나는 설명이 아닌 이름 untitled이고 다른 하나는 타임스탬프입니다. 무엇을 확인하기 위해 점 3개를 클릭한 다음 공유를 선택하면 엑스칼리드로 아이콘을 탭하면 파일에 I/O 로고만 포함된 것을 다시 확인할 수 있습니다.

지원 중단된 Electron 버전의 Lipis

아직 다루지 않은 파일 중 한 가지는 파일을 두 번 클릭하세요. 일반적으로 파일을 두 번 클릭하면 파일의 MIME 유형과 연결된 앱이 열립니다. 예를 들어 .docx의 경우 Microsoft Word입니다.

Excalidraw는 앱의 Electron 버전이 있었는데 이러한 파일 형식 연결을 지원하기 때문에 .excalidraw 파일을 더블클릭하면 Excalidraw Electron 앱이 열립니다. 전에 만났던 리피스가 크리에이터입니다. Excalidraw Electron의 지원 중단이었습니다. 나는 그에게 왜 Impact를 지원 중단하는 것이 가능하다고 느꼈는지 물었습니다. 전자 버전:

초창기부터 Electron 앱에 대한 요청이 있었습니다. 그 주된 이유는 두 번 클릭하여 파일을 열 수 있습니다. 또한, 앱 스토어에도 앱을 출시할 생각이었습니다. 동시에 누군가가 대신 PWA를 만들 것을 제안했으므로 둘 다 했습니다. 다행히 Project Fugu를 소개해 드렸지만 파일 시스템 액세스, 클립보드 액세스, 파일 처리 등의 API 클릭 한 번으로 Electron의 추가 부담 없이 앱을 데스크톱이나 모바일에 설치할 수 있습니다. 쉬웠음 Electron 버전을 지원 중단하고 웹 앱에만 집중하며 PWA로 할 수도 있습니다. 게다가, 이제 PWA를 Play 스토어와 Microsoft 저장! 엄청나군요!

Electron이 전혀 나쁘지 않기 때문에 Electron용 Excalidraw가 지원 중단되지 않았다고 말할 수 있습니다. 웹이 충분히 발전했기 때문이죠. 마음에 듭니다.

파일 처리

제가 '이제 웹이 훌륭해졌다'라고 말할 수 있는 이유는 출시 예정인 파일 기능 때문입니다. 취급.

다음은 일반 macOS Big Sur 설치입니다. 이제 확장 프로그램을 마우스 오른쪽 버튼으로 클릭하면 어떻게 되는지 Excalidraw 파일. 설치된 PWA인 Excalidraw로 열 수 있습니다. 물론 더블클릭도 효과가 있지만 스크린캐스트에서 보여주는 것은 덜 극적입니다.

어떻게 작동할까요? 첫 번째 단계는 애플리케이션이 처리할 수 있는 파일 형식을 실행할 수도 있습니다 웹 앱 매니페스트의 file_handlers라는 새 필드에서 이 작업을 수행합니다. 자체 value는 작업과 accept 속성이 있는 객체의 배열입니다. 작업에 따라 URL이 운영체제가 앱을 실행하는 경로와 허용 객체는 MIME의 키-값 쌍입니다. 파일 형식, 파일 확장자 등입니다.

{
  "name": "Excalidraw",
  "description": "Excalidraw is a whiteboard tool...",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "file_handlers": [
    {
      "action": "/",
      "accept": {
        "application/vnd.excalidraw+json": [".excalidraw"]
      }
    }
  ]
}

다음 단계는 애플리케이션이 시작될 때 파일을 처리하는 것입니다. 이 문제는 launchQueue에서 발생합니다. setConsumer()를 호출하여 소비자를 설정해야 하는 인터페이스입니다. 이 함수는 launchParams를 수신하는 비동기 함수입니다. 이 launchParams 객체 에는 파일이라는 필드가 있는데, 작업할 파일 핸들 배열을 가져옵니다. 저는 오직 이 파일 핸들에서 블롭을 얻고, 이를 오랜 친구에게 전달합니다. loadFromBlob()

if ('launchQueue' in window && 'LaunchParams' in window) {
  window as any.launchQueue
    .setConsumer(async (launchParams: { files: any[] }) => {
      if (!launchParams.files.length) return;
      const fileHandle = launchParams.files[0];
      const blob: Blob = await fileHandle.getFile();
      blob.handle = fileHandle;
      loadFromBlob(blob, this.state).then(({ elements, appState }) =>
        // Initialize app state.
      ).catch((error) => {
        this.setState({ isLoading: false, errorMessage: error.message });
      });
    });
}

다시 말하지만, 이 작업이 너무 빨리 진행된다면 내 기사를 확인하세요. 실험용 웹 플랫폼을 설정하여 파일 처리를 사용 설정할 수 있습니다. 기능 플래그에 대해 자세히 알아보세요. 올해 말 Chrome에서 출시될 예정입니다.

클립보드 통합

Excalidraw의 또 다른 멋진 기능은 클립보드 통합입니다. 내 그림 전체를 복사하거나 일부분만 클립보드에 붙여넣을 수도 있습니다. 원하는 경우 워터마크를 추가한 다음 사용할 수 있습니다. 참고로 이 앱은 Windows 95 Paint 앱의 웹 버전입니다.

작동 방식은 놀라울 정도로 간단합니다. 캔버스에 blob만 있으면 됩니다. blob이 있는 ClipboardItem가 있는 단일 요소 배열을 navigator.clipboard.write() 함수. 클립보드로 할 수 있는 작업을 자세히 알아보려면 API, Jason의 기사 및 내 기사를 참조하세요.

export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
  const blob = await canvasToBlob(canvas);
  await navigator.clipboard.write([
    new window.ClipboardItem({
      'image/png': blob,
    }),
  ]);
};

export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    try {
      canvas.toBlob((blob) => {
        if (!blob) {
          return reject(new CanvasError(t('canvasError.canvasTooBig'), 'CANVAS_POSSIBLY_TOO_BIG'));
        }
        resolve(blob);
      });
    } catch (error) {
      reject(error);
    }
  });
};

타인과의 공동작업

세션 URL 공유

Excalidraw에는 공동작업 모드도 있다는 사실을 알고 계셨나요? 여러 사람이 함께 작업할 수 있는 동일한 문서에 액세스할 수 있습니다 새 세션을 시작하기 위해 실시간 공동작업 버튼을 클릭한 다음 세션입니다. Excalidraw가 통합한 Web Share API

실시간 공동작업

Pixelbook의 Google I/O 로고 작업을 하여 로컬에서 공동작업 세션을 시뮬레이션했습니다. Pixel 3a 휴대전화와 iPad Pro에요. 한 기기에서 변경한 내용이 다른 모든 기기에서 작동합니다.

모든 커서가 이동하는 것도 볼 수 있습니다. Pixelbook이 제어되기 때문에 커서가 꾸준히 움직입니다. Pixel 3a 휴대전화의 커서와 iPad Pro의 태블릿 커서가 이리저리 움직이는데, 손가락으로 탭하여 해당 기기를 제어할 수 있습니다.

공동작업자 상태 보기

실시간 공동작업 환경을 개선하기 위해 유휴 감지 시스템도 실행됩니다. iPad Pro를 사용하면 커서에 녹색 점이 표시됩니다. 다른 컴퓨터로 전환하면 점이 검은색으로 다른 브라우저 탭이나 앱을 사용할 수 있습니다. Excalidraw 앱에서 아무 작업도 하지 않으면 세 개의 zZZ로 기호화된 유휴 상태로 표시됩니다.

Google 간행물의 열혈 독자라면 Idle Detection API는 살펴보겠습니다. 스포일러 주의: 그렇지 않습니다. 이 API를 기반으로 구현했지만 결국에는 측정 결과를 기반으로 보다 전통적인 접근 방식을 사용하기로 했습니다. 쉽게 관리할 수 있습니다.

WICG 유휴 감지 저장소에 제출된 유휴 감지 피드백의 스크린샷

Idle Detection API를 사용하는 이유에 관한 의견을 접수했습니다. 사용 사례를 해결하지 못했습니다. 모든 Project Fugu API는 개방형 환경에서 개발되기 때문에 모두가 참여하여 자신의 목소리를 낼 수 있습니다.

엑스칼리드로를 억누르는 리피스

이에 대해 이야기하면서, 저는 그가 웹에서 무엇을 놓치고 있다고 생각하는지에 대해 마지막 질문을 했습니다. 다음과 같습니다.

File System Access API도 훌륭하지만 뭔지 아세요? 요즘 관심 있는 대부분의 파일 하드 디스크가 아닌 Dropbox나 Google Drive에 있습니다. File System Access API가 Dropbox나 Google과 같은 원격 파일 시스템 공급자를 위해 추상화 계층을 포함하여 개발자들이 코딩할 수 있는 환경을 제공합니다. 그런 다음 사용자는 긴장을 풀고 파일이 안전한지 확인할 수 있습니다. 협력해야 합니다

저도 클라우드에 살고 있다는 생각에 완전히 동의합니다. 이 기술을 통해 출시 예정.

탭으로 표시된 애플리케이션 모드

와우! Excalidraw의 API는 정말로 여러 번 원활하게 통합되는 것을 보았습니다. 파일 시스템, 파일 처리 클립보드, 웹 공유웹 공유 타겟을 선택합니다. 하지만 한 가지 더 있습니다. 지금까지는 편집할 수 있습니다. 더 이상은 그럴 필요가 없습니다. 의 초기 버전을 처음 사용하는 경우 Excalidraw의 탭으로 표시된 애플리케이션 모드입니다. 다음과 같습니다.

설치된 Excalidraw PWA에 독립형 모드로 실행되는 기존 파일이 열려 있습니다. 진행 중 독립형 창에서 새 탭을 엽니다. 일반 브라우저 탭이 아니라 PWA 탭입니다. 이 새 탭에서 보조 파일을 열고 같은 앱 창에서 독립적으로 작업할 수 있습니다.

탭으로 구분된 애플리케이션 모드는 초기 단계이며 모든 기능이 고정되어 있지는 않습니다. 만약 관심이 있는 경우 다음 페이지에서 이 기능의 현재 상태를 읽어 보시기 바랍니다. 내 기사를 확인하세요.

마무리

이 기능과 다른 기능에 대한 소식을 계속 받아보려면 Fugu API 추적기 웹을 발전시켜 나갈 수 있게 되어 매우 기쁘고 플랫폼에서 더 많은 작업을 할 수 있습니다. 계속해서 발전하는 Excalidraw 및 만들 수 있습니다. 다음에서 제작 시작 excalidraw.com.

오늘 보여드린 API 중 일부가 여러분의 앱에 나타나기를 기대하고 있겠습니다. 저는 톰입니다. 저는 트위터와 인터넷에서 @tomayac으로 보내주실 수 있습니다. 시청해 주셔서 감사합니다. Google I/O의 나머지 부분도 즐겁게 시청하세요.