你将要开发什么

在本次 codelab 中,你将会做一个 Flutter app,名字叫做 Field Trippa,它可以让用户之间互相分享照片。学习怎样使用 Google Photos Library API,来帮你积累社交媒体分享的开发经验。

你将会学到什么

你需要准备什么

对 Field Trippa 的概述

在本次的 codelab 里,你将要开发一个 app,调用 Google Photos Library API 来分享旅行的照片。

用户首先用 Google 登录来授权我们的 app 可以正确调用 Google Photos Library API。

用户可以创建一个 trip ,即旅行影集。用它来上传照片,并可以附加照片描述。每一个 trip 都可以分享给 app 的其他使用者,让他们一起来上传照片。

在内部实现上,每一个 trip 都是 Google Photos 上的一个共享影集。我们的 app 负责处理分享功能和照片上传,当然你也可以通过一个可访问 Google Photos 的 URL 分享给没有安装我们 app 的用户。

下载本次 codelab 的源码:

下载源码 访问 GitHub 页

(初始的 app 代码在 仓库master 分支。)

下载并解压 zip 文件,得到一个 photos-sharing-master 文件夹。里面包含全部的初始代码。

用你常用的 Flutter IDE 打开这个文件夹,例如用 VSCode,或者装有 Dart 和 Flutter 插件的 Android Studio。

最终实现的源码

以下链接定向到 app 的最终实现。当你卡壳了或者想对比一下代码时,可以用到它们:

下载最终实现的代码 在 GitHub 上浏览最终实现的源码

(最终实现的代码在 仓库final 分支)

如果你之前还没有开发过 Flutter,请先阅读 搭建 Flutter 开发环境

要运行 Field Trippa,你可以在 IDE 中点击 run 按钮,或在项目根目录运行以下命令:

flutter run

运行之后可看到带 Connect with Google Photos 按钮的界面:

Google Photos Library API 遵循 OAuth 2.0 协议进行授权认证。授权认证请求以 API 的形式发送给授权应用,用户在登录状态下方可进行处理。

创建一个 Firebase 项目并注册你的 app

去到 Firebase 控制台 并选择 + Add Project 。输入项目名称,点击 Create Project 。Firebase 控制台里提到的后续步骤就请先略过。返回到我们的 codelab,请继续完成 Android 或 iOS 配置的部分。

针对 Android 设备:如果你要将 app 运行在 Android 设备之上,请先注册一个 Android app:

点击 Android 图标,以进入到 Android app 的注册页。

package 一栏输入: com.google.codelab.photos.sharing

在你电脑中获得 SHA-1 签名证书:运行以下命令:

Keytool -alias androiddebugkey -keystore ~/.android/debug.keystore -list -v -storepass android

点击 next。

下载 google-service.json 到你的电脑,并将其放置在 android/app/ 路径下。(小贴士:在 Android Studio 里,你可以在 project 侧边栏里直接将下载好的文件拖拽到指定位置。)

该文件已为项目配置好了 Firebase 和 Google 开发者的信息。

(点击 package google_sign_in 文档查看详情)

针对 iOS 设备:如果你要将 app 运行在 iOS 设备之上,请先注册一个 iOS app:

点击 iOS 图标,以进入到 iOS app 的注册页。

iOS bundle ID 一栏输入: com.google.codelab.photos.sharing

点击 next。

下载 GoogleService-Info.plist 文件到你的电脑。

用 Xcode 打开该 Flutter 项目。

右击 Runner 文件夹,选择 Add Files to Runner ,并选中刚刚下载的 GoogleService-Info.plist,将其添加到 Runner 模块里。

打开 ios/Runner/Info.plist,在 GoogleService-Info.plist 找到 REVERSE_CLIENT_ID ,将对应值替换在代码示例所示位置:

ios/Runner/Info.plist

<!-- Google Sign-in Section -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleTypeRole</key>
    <string>Editor</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>COPY_REVERSE_CLIENT_ID_HERE</string>
    </array>
  </dict>
</array>
<!-- End of the Google Sign-in Section -->

(点击 package google_sign_in 文档查看详情)

启用 Google Photos Library API

打开 Google 开发者控制台的 API 页 并启用 Google Photos Library API。(如果启用按钮不可点击,请先在页面上方选择刚创立的 Firebase 项目)

打开 Google 开发者控制台 OAuth 同意页 添加 Google Photos Library API 到 API 使用范围里,并输入你的邮箱地址。(任何 Google Photos Library API 的访问范围都需要 Oauth 认证,此步骤的配置是必须的)

https://www.googleapis.com/auth/photoslibrary
https://www.googleapis.com/auth/photoslibrary.sharing

运行 app 并登录

Google 登录已经集成在 google_sign_in 的 flutter package 中了。该 package 所需的 google-services.json 或者 GoogleService-Info.plist 文件已经放在项目中。

再次运行应用,点击 Connect to Google Photos 会弹出选择账户的提示,这里需要用户的登录授权。

如果配置一切顺利,你会看到一个列表为空的页面。

在调用 Google Photos Library API 之前,让我们先准备好 Field Trippa 要用到的数据。

应用结构

调用创建影集的 API

我们每一次的旅行都会作为一个影集存放在 Google Photos 之中。当你点击 CREATE A TRIP ALBUM 按钮时,应用会提示你输入这次旅行的名字,随后应用会以该名字创建出一个新影集。

我们在 create_trip_page.dart 文件中,编写创建影集 API 调用的代码。在文件末尾的 _createTrip(...) 方法里,进行对 PhotosLibraryApiModel 的调用,并传入用户输入的影集名。

lib/pages/create_trip_page.dart

Future<void> _createTrip(BuildContext context) async {
  // Display the loading indicator.
  setState(() => _isLoading = true);

  await ScopedModel.of<PhotosLibraryApiModel>(context)
      .createAlbum(tripNameFormController.text)
      .then((Album album) {

    // Hide the loading indicator.
    setState(() => _isLoading = false);
    Navigator.pop(context);
  });
}

接下来完成调用 API 的工作。在 PhotosLibraryApiModel 中,让我们来完善 createAlbum(...) 方法,此方法要求影集名称作为参数传入。它会去调用 PhotosLibraryApiClient ,那里是真正发生 REST 调用的地方。

lib/model/photos_library_api_model.dart

Future<Album> createAlbum(String title) async {
  return client
      .createAlbum(CreateAlbumRequest.fromTitle(title))
      .then((Album album) {
    updateAlbums();
    return album;
  });
}

photos_library_api_client.dart 文件中完成创建影集的 REST 调用。请注意,方法参数 CreateAlbumRequest 对象中已经包含了 title 属性。

随后,将数据转换成 JSON 格式,并添加认证相关的 header。最后返回由 API 创建好的 Album 对象。

lib/photos_library_api/photos_library_api_client.dart

Future<Album> createAlbum(CreateAlbumRequest request) async {
  return http
      .post(
    'https://photoslibrary.googleapis.com/v1/albums',
    body: jsonEncode(request),
    headers: await _authHeaders,
  )
      .then(
    (Response response) {
      if (response.statusCode != 200) {
        print(response.reasonPhrase);
        print(response.body);
      }
      return Album.fromJson(jsonDecode(response.body));
    },
  );
}

动手试试看!

部署应用之后,点击 + Create Trip

你可能注意到了,旅行列表里还显示了一些并不是由我们的应用创建的 Google Photos 影集。(如果你在 Google Photos 里没有其他的影集,但想看到此效果,那就请打开 Google Photos 应用,在里面创建一个新影集吧。当然,这不是本 codelab

所必须要求做的。)

我们每一次的旅行都作为一个影集存放在 Google Photos 里。但是,如果我们的应用中展示了一些与旅行无关的影集,就很说不通。也就是说,Field Trippa 应该只展示在应用内创建过的旅行影集。

你可以通过 API 调用来实现上述功能,让我们的列表只包含是在应用中创建过的影集。

photos_library_api_client.dart 中修改 listAlbums() 方法实现(请注意,不是 listSharedAlbums()方法!)。 该方法发起 REST 调用用来获取影集列表。添加 excludeNonAppCreatedData=true 参数,可让返回的数据剔除掉非本应用创建的影集。

lib/photos_library_api/photos_library_api_client.dart

Future<ListAlbumsResponse> listAlbums() async {
  return http
      .get(
          'https://photoslibrary.googleapis.com/v1/albums?'
          'excludeNonAppCreatedData=true&pageSize=50',
          headers: await _authHeaders)
       ...
}

动手试试看!

现在,应用首页中只展示由我们应用所创建出来的影集啦。

接下来是上传旅行的照片。照片数据存储在对应账户的 Google Photos 中。所以你不必担心要自己处理数据存储。

用 Flutter 实现拍照

首先完成 ContributePhotoDialog 类里 _getImage(...) 方法。当用户点击 +ADD PHOTO 按钮时,该方法会被调用。

方法内使用 image_picker package 进行照片选取、UI 更新,以及调用上传 API(上传会在下一步完成)。 _getImage(...) 方法会将上传的 token 存起来,稍后在 Google Photos 里创建照片时会用到。

lib/components/contribute_photo_dialog.dart

Future _getImage(BuildContext context) async {
  // Use the image_picker package to prompt the user for a photo from their
  // device.
  final File image = await ImagePicker.pickImage(
    source: ImageSource.camera,
  );

  // Store the image that was selected.
  setState(() {
    _image = image;
    _isUploading = true;
  });

  // Make a request to upload the image to Google Photos once it was selected.
  final String uploadToken =
      await ScopedModel.of<PhotosLibraryApiModel>(context)
          .uploadMediaItem(image);

  setState(() {
    // Once the upload process has completed, store the upload token.
    // This token is used together with the description to create the media
    // item later.
    _uploadToken = uploadToken;
    _isUploading = false;
  });
}

Implement Library API call to upload the image to get an upload token

调用 API 上传图片并获得 upload token

调用 API 上传照片或者视频需要两步:

  1. 将媒体字节数据上传,并拿到一个 upload token
  2. 用拿到的 token 在用户的媒体库中创建一个新的媒体资源项

调用 REST 请求上传媒体资源。你需要在请求的 header 里额外声明上传类型和文件名。在 photos_library_api_client.dart 文件里, uploadMediaItem(...) 方法用来将文件进行上传,并通过 HTTP 请求拿到 upload token:

lib/photos_library_api/photos_library_api_client.dart

Future<String> uploadMediaItem(File image) async {
  // Get the filename of the image
  final String filename = path.basename(image.path);
  // Set up the headers required for this request.
  final Map<String, String> headers = <String,String>{};
  headers.addAll(await _authHeaders);
  headers['Content-type'] = 'application/octet-stream';
  headers['X-Goog-Upload-Protocol'] = 'raw';
  headers['X-Goog-Upload-File-Name'] = filename;
  // Make the HTTP request to upload the image. The file is sent in the body.
  return http
      .post(
    'https://photoslibrary.googleapis.com/v1/uploads',
    body: image.readAsBytesSync(),
    headers: await _authHeaders,
  )
      .then((Response response) {
    if (response.statusCode != 200) {
      print(response.reasonPhrase);
      print(response.body);
    }
    return response.body;
  });
}

利用 upload token 创建一个媒体资源项

接下来,用拿到的 token 在用户的媒体库中创建一个新的媒体资源项。

在创建时,可以增加一些额外描述(例如,音频或视频的字幕),以及影集的标识等。Field Trippa 会将上传的照片直接添加到旅行的影集里。

photos_library_api_clientmediaItems.batchCreate 会带上 upload toke、描述、以及影集 ID。完成 createMediaItem(...) 方法来调用此 API。方法会将资源项作为返回值返回。

photos_library_client 里关于 API 的调用已经实现好了。)

lib/model/photos_library_api_model.dart

Future<BatchCreateMediaItemsResponse> createMediaItem(
    String uploadToken, String albumId, String description) {
  // Construct the request with the token, albumId and description.
  final BatchCreateMediaItemsRequest request =
      BatchCreateMediaItemsRequest.inAlbum(uploadToken, albumId, description);

  // Make the API call to create the media item. The response contains a
  // media item.
  return client
      .batchCreateMediaItems(request)
      .then((BatchCreateMediaItemsResponse response) {
    // Print and return the response.
    print(response.newMediaItemResults[0].toJson());
    return response;
  });
}

动手试试看!

打开 app 选择某一次旅行。点击 contribute 并选一张你拍好的照片。输入照片描述并点击 upload。几秒过后,照片就应该会显示在 app 里。

在 Google Photos 应用中打开此影集。你会看到新照片已经在里面了。

到目前为止,你已经实现了基本的功能,创建了一个旅行影集,并上传了带有描述的照片。在后端,每一段旅行都作为一个单独的影集存放在了 Google Photos 上面。

接下来,你将要和未安装应用的用户分享你的旅行。

每一段旅行都作为影集存放在 Google Photos 上,因此你可以用 URL 的方式来分享影集,让每一位用户都可以访问。

共享影集的实现

在 app 旅行影集页面的顶部,有一个分享的按钮,点击可进行分享。

实现异步调用方法 _shareAlbum(...) 。该方法可以调用相关 model 分享影集,并刷新影集显示。 shareInfo 对象里包含了用于给用户显示的分享链接 shareableUrl

lib/pages/trip_page.dart

Future<void> _shareAlbum(BuildContext context) async {
  // Show the loading indicator
  setState(() {
    _inSharingApiCall = true;
  });
  final SnackBar snackBar = SnackBar(
    duration: Duration(seconds: 3),
    content: const Text('Sharing Album...'),
  );
  Scaffold.of(context).showSnackBar(snackBar);
  // Share the album and update the local model
  await ScopedModel.of<PhotosLibraryApiModel>(context).shareAlbum(album.id);
  final Album updatedAlbum =
      await ScopedModel.of<PhotosLibraryApiModel>(context).getAlbum(album.id);
  print('Album has been shared.');
  setState(() {
    album = updatedAlbum;
    // Hide the loading indicator
    _inSharingApiCall = false;
  });
}

实现 _showShareableUrl(...) 方法。当用户点击页面顶部的 SHARE WITH ANYONE 按钮时该方法会被调用。该方法首先会检查影集是否已经被分享过,如果被分享过则会直接调用 _showUrlDialog(...)

lib/pages/trip_page.dart

void _showShareableUrl(BuildContext context) {
  if (album.shareInfo == null || album.shareInfo.shareableUrl == null) {
    print('Not shared, sharing album first.');
    // Album is not shared yet, share it first, then display dialog
    _shareAlbum(context).then((_) {
      _showUrlDialog(context);
    });
  } else {
    // Album is already shared, display dialog with URL
    _showUrlDialog(context);
  }
}

实现 _showUrlDialog(...) 方法。该方法会将 shareableUrl 显示在一个弹窗里。

lib/pages/trip_page.dart

void _showUrlDialog(BuildContext context) {
  print('This is the shareableUrl:\n${album.shareInfo.shareableUrl}');

  _showShareDialog(
      context,
      'Share this URL with anyone. '
      'Anyone with this URL can access all items.',
      album.shareInfo.shareableUrl);
}

动手试试看

我们的应用只会列出在主屏幕还未分享过的旅行。不用担心,我们下一步就会来实现它。现在你可以先简单创建一个旅行。

打开 app 并选择一段旅行。点击页面顶部的 SHARE WITH ANYONE,将展示出的 URL 用浏览器打开。(小贴士:URL 会在 log 里打印出来,你可以非常轻松的将 URL 复制到你的电脑里。在 Android Studio 中,log 在 Run tab 中可以找到)

在 Google Photos 中,影集可以通过 URL 的方式分享,其他人可以通过该 URL 看到影集。通过 API 调用,你可以用 share token 的方式分享影集。share token 是一个字符串,它可以在应用内使用,让其他用户看的到你分享出来的影集。

通过 API 进行应用内影集分享,流程大致如下:

  1. 用户 A 进入应用并授权登录
  2. 创建一个影集
  3. 分享影集
  4. 将 share token 发送给另一个用户

加入影集的步骤也很简单:

  1. 用户 B 进入应用并授权登录
  2. 拿到相应影集的 share token
  3. 使用 share token 加入该影集

影集分享功能在 Google Photos 的 sharing tab 里。

显示 share token

在之前的步骤中,你已经完成了 _shareAlbum(...) 方法,该方法用来分享一个影集。

shareInfo 中当然也包含要显示的 shareToken。

在旅行影集页,完成 _showShareToken(...) 方法。它会在用户点击 SHARE WITH FIELD TRIPPA 按钮时被调用。

lib/pages/trip_page.dart

void _showShareToken(BuildContext context) {
  if (album.shareInfo == null) {
    print("Not shared, sharing album first.");
    // Album is not shared yet, share it first, then display dialog
    _shareAlbum(context).then((_) {
      _showTokenDialog(context);
    });
  } else {
    // Album is already shared, display dialog with token
    _showTokenDialog(context);
  }
}

接下来,实现 _showTokenDialog(...) 方法,它会将 share token 显示在屏幕上。token 是 shareInfo 里的一个字段。

lib/pages/trip_page.dart

void _showTokenDialog(BuildContext context) {
  print('This is the shareToken:\n${album.shareInfo.shareToken}');

  _showShareDialog(
      context, 'Use this token to share', album.shareInfo.shareToken);
}

列出分享的影集

应用目前只会列出用户创建的影集,还不会显示出分享来的影集。

只有用户创建的,或者手动添加的影集,才会在 Google Photos 的"影集"页里展示出来。这部分的影集可以通过 API 的 albums.list 拿到。然而,在我们的 app 中,用户可以加入其它用户共享出来的影集,这一部分数据可通过另外一个 API 拿到。你需要改变一下旅行影集获取的实现方式,需要通过 API 调用既拿到用户创建的影集,又要拿到分享的影集。

在 API 相关的 Model 中,实现了影集数据的加载和缓存。 updateAlbums() 方法会分别加载用户影集数据和共享影集数据,然后将数据合并。

该实现用到了 multiple Futures ,它让两个 list 请求分别进行,然后将他们两个请求的数据进行合并,返回一个影集的列表。将方法里原有的代码删去,并提交新的代码。

lib/model/photos_library_api_model.dart

void updateAlbums() async {
  // Reset the flag before loading new albums
  hasAlbums = false;

  // Clear all albums
  _albums.clear();

  // Add albums from the user's Google Photos account
  // var ownedAlbums = await _loadAlbums();
  // if (ownedAlbums != null) {
  //   _albums.addAll(ownedAlbums);
  // }

  // Load albums from owned and shared albums
  final List<List<Album>> list =
      await Future.wait([_loadSharedAlbums(), _loadAlbums()]);
  for (final List<Album> albumListResponse in list) {
    if (albumListResponse != null) {
      _albums.addAll(albumListResponse);
    }
  }
  notifyListeners();
  hasAlbums = true;
}

加入一个共享影集

你可以通过 share token 在应用中加入其他用户的影集。在 codelab 中用一个简单的文字弹窗就可以实现。

在 join_trip_page 中完成 _joinTrip 方法,该方法会用到用输入的 token,并调用 model 层的方法。首先先显示一个"加载中"的标识,然后拿到输入框的输入内容,去调用加入共享影集的 API,随后隐藏"加载中",并返回上一页面。

lib/pages/join_trip_page.dart

Future<void> _joinTrip(BuildContext context) async {
  // Show loading indicator
  setState(() => _isLoading = true);

  // Call the API to join an album with the entered share token
  await ScopedModel.of<PhotosLibraryApiModel>(context)
      .joinSharedAlbum(shareTokenFormController.text);

  // Hide loading indicator
  setState(() => _isLoading = false);

  // Return to the previous screen
  Navigator.pop(context);
}

动手试试看

你需要两台设备或者模拟器,用不同的账户登录,来测试本节的 codelab。

先用一个账户创建并分享一个影集。点击 SHARE IN FIELD TRIPPA 来获得 share token。在另外一台设备/模拟器上,在应用的首页点击 JOIN A TRIP ALBUM,将刚才的 share token 复制过来。(小贴士:模拟器和其运行电脑的剪贴板是相关联的)

对实际生产开发的建议

在实际生产开发中(而不是 codelab 中),当你想用 share token 的方式进行影集共享时,请慎重考虑。请将 token 存放在安全性更高的后端,并利用 app 内已有的用户关系进行影集的创建和加入。

举一个实际产品的例子,一个足球俱乐部见面 app 需要维护每次活动的参与者,只有参与活动的人可以加入相应的影集。

在对用户的 Google Photos 账户做任何修改时,给与用户提醒和同意授权是非常重要的。请查阅 Google Photos Library API UX guidelines 获得更多信息。

知识点总结

接下来可以做什么

可以查阅 Google Photos API 的开发者文档,地址是 https://developers.google.com/photos, 里面有更多与 多媒体共享 和其他 API 的使用介绍。例如利用由机器学习驱动的 智能内容过滤 可以帮助你找到合适的照片和视频。

当你已经准备好要发布结合 Google Photos 功能的产品时,欢迎加入 Google Photos partner program

不要忘记时常回顾 UX 指南开发最佳实践。为了帮助你起步,我们也准备好了不同语言的 客户端类库

特别感谢