向 Flutter 应用添加主屏幕 widget

1. 简介

什么是微件?

对于 Flutter 开发者,微件的常见定义是指使用 Flutter 框架创建的界面组件。在本 Codelab 中,微件是指迷你版应用,让用户无需打开应用即可查看应用的信息。在 Android 上,微件位于主屏幕上。在 iOS 设备上,用户可以将其添加到主屏幕、锁定屏幕或“今天”视图中。

f0027e8a7d0237e0.png b991e79ea72c8b65.png

widget 的复杂程度如何?

大多数主屏幕微件都很简单。这类控件可能包含基本文本、简单图形,在 Android 设备上则可能包含基本控件。Android 和 iOS 都会限制您可以使用的界面组件和功能。

819b9fffd700e571 92d62ccfd17d770d.png

为 widget 创建界面

由于这些界面限制,您无法使用 Flutter 框架直接绘制主屏幕 widget 的界面。相反,您可以将使用 Jetpack Compose 或 SwiftUI 等平台框架创建的 widget 添加到 Flutter 应用。此 Codelab 讨论了如何在应用和 widget 之间共享资源,以避免重写复杂的界面。

构建内容

在此 Codelab 中,您将使用 home_widget 软件包(可让用户阅读文章)在 Android 和 iOS 上为一个简单的 Flutter 应用构建主屏幕 widget。您的微件将:

  • 显示来自 Flutter 应用的数据。
  • 使用从 Flutter 应用共享的字体资源显示文本。
  • 显示渲染的 Flutter widget 的图片。

a36b7ba379151101.png

此 Flutter 应用包含两个屏幕(或路由):

  • 第一个页面会显示新闻报道列表,其中包含标题和说明。
  • 第二个窗格会显示完整的报道,以及使用 CustomPaint 创建的图表。

.

9c02f8b62c1faa3a d97d44051304cae4.png

学习内容

  • 如何在 iOS 和 Android 上创建主屏幕微件。
  • 如何使用 home_widget 软件包在主屏幕 widget 和 Flutter 应用之间共享数据。
  • 如何减少需要重写的代码量。
  • 如何从 Flutter 应用更新主屏幕 widget。

2. 设置您的开发环境

对于这两个平台,您都需要 Flutter SDKIDE。您可以使用自己偏好的 IDE 来处理 Flutter。这可以是带有 Dart Code 和 Flutter 扩展程序的 Visual Studio Code,或者安装了 Flutter 和 Dart 插件的 Android Studio 或 IntelliJ。

如需创建 iOS 主屏幕微件,请执行以下操作

  • 您可以在实体 iOS 设备或 iOS 模拟器上运行此 Codelab。
  • 您必须使用 Xcode IDE 配置 macOS 系统。这会安装构建 iOS 版本应用所需的编译器。

如需创建 Android 主屏幕微件,请执行以下操作

  • 您可以在实体 Android 设备或 Android 模拟器上运行此 Codelab。
  • 您必须使用 Android Studio 配置您的开发系统。这会安装构建 Android 版本应用所需的编译器。

获取起始代码

从 GitHub 下载项目的初始版本

在命令行中,将 GitHub 代码库克隆到 flutter-codelabs 目录:

$ git clone https://github.com/flutter/codelabs.git flutter-codelabs

克隆代码库后,您可以在 flutter-codelabs/homescreen_codelab 目录中找到此 Codelab 的代码。此目录包含此 Codelab 中每个步骤的完整项目代码。

打开起始应用

打开 flutter-codelabs/homescreen_codelab/step_03 目录并进入首选 IDE。

安装软件包

所有必需的软件包都已添加到项目的 pubspec.yaml 文件中。如需检索项目依赖项,请运行以下命令:

$ flutter pub get

3. 添加基本的主屏幕微件

首先,使用原生平台工具添加主屏幕微件。

创建基本的 iOS 主屏幕 widget

向 Flutter iOS 应用添加附加应用信息类似于向 SwiftUI 或 UIKit 应用添加附加应用信息:

  1. 从 Flutter 项目目录在终端窗口中运行 open ios/Runner.xcworkspace。或者,右键点击 VSCode 中的 ios 文件夹,然后选择在 Xcode 中打开。这会在您的 Flutter 项目中打开默认的 Xcode 工作区。
  2. 从菜单中选择 File → New → Target。此操作会向项目中添加一个新目标。
  3. 系统会显示模板列表。选择 Widget Extension
  4. 输入“NewsWidgets”输入此 widget 的 Product Name 框中。取消选中 Include Live ActivityInclude Configuration Intent 复选框。

检查示例代码

当您添加新的目标时,Xcode 会根据您选择的模板生成示例代码。有关生成的代码和 WidgetKit 的详细信息,请参阅 Apple 的附加应用信息文档

调试和测试示例 widget

  1. 首先,更新您的 Flutter 应用的配置。当您在 Flutter 应用中添加新软件包,并计划从 Xcode 在项目中运行目标时,必须执行此操作。如需更新应用的配置,请在 Flutter 应用目录中运行以下命令:
$ flutter build ios --config-only
  1. 点击 Runner 以显示目标列表。选择您刚刚创建的 widget 目标 NewsWidgets,然后点击 Run。更改 iOS 微件代码时,从 Xcode 运行微件目标。

bbb519df1782881d.png

  1. 模拟器或设备屏幕应显示一个基本的主屏幕 widget。如果您没有看到此图标,可以将其添加到屏幕上。点击并按住主屏幕,然后点击左上角的 +

18eff1cae152014d

  1. 搜索应用的名称。对于此 Codelab,请搜索“主屏幕微件”

a0c00df87615493e.png

  1. 添加主屏幕微件后,它应该会显示提供时间的简单文本。

创建基本的 Android widget

  1. 如需在 Android 中添加主屏幕微件,请在 Android Studio 中打开项目的 build 文件。您可以在 android/build.gradle 下找到此文件。或者,右键点击 VSCode 中的 android 文件夹,然后选择 Open in Android Studio
  2. 项目构建完成后,在左上角找到应用目录。将新的主屏幕微件添加到此目录中。右键点击该目录,选择 New ->微件 ->应用微件

f19d8b7f95ab884e.png

  1. Android Studio 会显示一个新表单。添加有关主屏幕微件的基本信息,包括其类名称、位置、大小和源语言

对于此 Codelab,请设置以下值:

  • Class Name 框添加到 NewsWidget
  • 最小宽度(单元格)下拉菜单设置为 3
  • 最小高度(单元格)下拉菜单设置为 3

检查示例代码

在您提交表单时,Android Studio 会创建并更新多个文件。下表列出了与此 Codelab 相关的更改

操作

目标文件

更改

更新

AndroidManifest.xml

添加一个用于注册 NewsWidget 的新接收器。

创建

res/layout/news_widget.xml

定义主屏幕 widget 界面。

创建

res/xml/news_widget_info.xml

定义主屏幕 widget 配置。您可以在此文件中调整微件的尺寸或名称。

创建

java/com/example/homescreen_widgets/NewsWidget.kt

包含用于向主屏幕 widget 添加功能的 Kotlin 代码。

您可以在此 Codelab 中找到关于这些文件的更多详细信息。

调试和测试示例 widget

现在,运行应用并查看主屏幕 widget。构建应用后,前往 Android 设备的应用选择界面,长按此 Flutter 项目的图标。从弹出式菜单中选择 Widgets

dff7c9f9f85ef1c7.png

Android 设备或模拟器会显示 Android 的默认主屏幕微件。

4. 将数据从 Flutter 应用发送到主屏幕 widget

您可以自定义您创建的基本主屏幕微件。更新主屏幕微件,以显示新闻报道的标题和摘要。以下屏幕截图显示了主屏幕 widget 的示例,其中显示了标题和摘要。

acb90343a3e51b6d.png

如需在应用与主屏幕微件之间传递数据,您需要编写 Dart 和原生代码。本部分将此过程分为三个部分:

  1. 在 Flutter 应用中编写 Android 和 iOS 都可以使用的 Dart 代码
  2. 添加原生 iOS 功能
  3. 添加原生 Android 功能

使用 iOS 应用组

如需在 iOS 父级应用和微件扩展程序之间共享数据,这两个目标必须属于同一应用组。如需详细了解应用组,请参阅 Apple 的应用组文档

更新您的软件包标识符

在 Xcode 中,前往目标的设置。在签署和功能标签页中,检查您的团队和软件包标识符是否已设置。

将应用组添加到 Xcode 中的 Runner 目标和 NewsWidgetExtension 目标:

选择 + 功能 ->应用组,然后添加新的应用组。对 Runner(父应用)目标和 widget 目标重复上述操作。

135e1a8c4652dac

添加 Dart 代码

iOS 和 Android 应用都可以通过几种不同的方式与 Flutter 应用共享数据。如需与这些应用通信,请使用设备的本地 key/value 商店。iOS 将此商店称为 UserDefaults,Android 将此商店称为 SharedPreferenceshome_widget 软件包会封装这些 API 以简化将数据保存到任一平台的过程,并使主屏幕 widget 能够提取更新后的数据。

707ae86f6650ac55

标题和广告内容描述数据来自 news_data.dart 文件。此文件包含模拟数据和 NewsArticle 数据类。

lib/news_data.dart

class NewsArticle {
  final String title;
  final String description;
  final String? articleText;

  NewsArticle({
    required this.title,
    required this.description,
    this.articleText = loremIpsum,
  });
}

更新标题和广告内容描述值

如需添加从 Flutter 应用更新主屏幕 widget 的功能,请前往 lib/home_screen.dart 文件。将文件的内容替换为以下代码。然后,将 <YOUR APP GROUP> 替换为您的应用组的标识符。

lib/home_screen.dart

import 'package:flutter/material.dart';
import 'package:home_widget/home_widget.dart';             // Add this import

import 'article_screen.dart';
import 'news_data.dart';

// TODO: Replace with your App Group ID
const String appGroupId = '<YOUR APP GROUP>';              // Add from here
const String iOSWidgetName = 'NewsWidgets';
const String androidWidgetName = 'NewsWidget';             // To here.

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

void updateHeadline(NewsArticle newHeadline) {             // Add from here
  // Save the headline data to the widget
  HomeWidget.saveWidgetData<String>('headline_title', newHeadline.title);
  HomeWidget.saveWidgetData<String>(
      'headline_description', newHeadline.description);
  HomeWidget.updateWidget(
    iOSName: iOSWidgetName,
    androidName: androidWidgetName,
  );
}                                                          // To here.

class _MyHomePageState extends State<MyHomePage> {

  @override                                                // Add from here
  void initState() {
    super.initState();

    HomeWidget.setAppGroupId(appGroupId);

    // Mock read in some data and update the headline
    final newHeadline = getNewsStories()[0];
    updateHeadline(newHeadline);
  }                                                        // To here.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: const Text('Top Stories'),
            centerTitle: false,
            titleTextStyle: const TextStyle(
                fontSize: 30,
                fontWeight: FontWeight.bold,
                color: Colors.black)),
        body: ListView.separated(
          separatorBuilder: (context, idx) {
            return const Divider();
          },
          itemCount: getNewsStories().length,
          itemBuilder: (context, idx) {
            final article = getNewsStories()[idx];
            return ListTile(
              key: Key('$idx ${article.hashCode}'),
              title: Text(article.title!),
              subtitle: Text(article.description!),
              onTap: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (context) {
                      return ArticleScreen(article: article);
                    },
                  ),
                );
              },
            );
          },
        ));
  }
}

updateHeadline 函数会将键值对保存到设备的本地存储空间中。headline_title 键包含 newHeadline.title 的值。headline_description 键包含 newHeadline.description 的值。该函数还会通知原生平台可以检索和呈现主屏幕微件的新数据。

修改 floatActionButton

在按下 floatingActionButton 时调用 updateHeadline 函数,如下所示:

lib/article_screen.dart

// New: import the updateHeadline function
import 'home_screen.dart';

...

floatingActionButton: FloatingActionButton.extended(
        onPressed: () {
          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
            content: Text('Updating home screen widget...'),
          ));
          // New: call updateHeadline
          updateHeadline(widget.article);
        },
        label: const Text('Update Homescreen'),
      ),
...

进行此更改后,当用户从报道页面按 Update Headline 按钮时,主屏幕 widget 的详细信息会更新。

更新 iOS 代码以显示报道数据

如需为 iOS 更新主屏幕微件,请使用 Xcode。

在 Xcode 中打开 NewsWidgets.swift 文件:

配置 TimelineEntry

SimpleEntry 结构体替换为以下代码:

ios/NewsWidgets/NewsWidgets.swift

// The date and any data you want to pass into your app must conform to TimelineEntry
struct NewsArticleEntry: TimelineEntry {
    let date: Date
    let title: String
    let description:String
}

NewsArticleEntry 结构体定义了在更新时要传入主屏幕 widget 的传入数据。TimelineEntry 类型需要日期参数。如需详细了解 TimelineEntry 协议,请参阅 Apple 的 TimelineEntry 文档

修改显示相应内容View

修改主屏幕微件,显示新闻报道标题和说明(而非日期)。如需在 SwiftUI 中显示文本,请使用 Text 视图。如需在 SwiftUI 中堆叠视图,请使用 VStack 视图。

将生成的 NewsWidgetEntryView 视图替换为以下代码:

ios/NewsWidgets/NewsWidgets.swift

//View that holds the contents of the widget
struct NewsWidgetsEntryView : View {
    var entry: Provider.Entry

    var body: some View {
      VStack {
        Text(entry.title)
        Text(entry.description)
      }
    }
}

修改提供程序,告知主屏幕 widget 何时以及如何更新

将现有 Provider 替换为以下代码。然后,用您的应用组标识符替换 <YOUR APP GROUP>:

ios/NewsWidgets/NewsWidgets.swift

struct Provider: TimelineProvider {

// Placeholder is used as a placeholder when the widget is first displayed
    func placeholder(in context: Context) -> NewsArticleEntry {
//      Add some placeholder title and description, and get the current date
      NewsArticleEntry(date: Date(), title: "Placeholder Title", description: "Placeholder description")
    }

// Snapshot entry represents the current time and state
    func getSnapshot(in context: Context, completion: @escaping (NewsArticleEntry) -> ()) {
      let entry: NewsArticleEntry
      if context.isPreview{
        entry = placeholder(in: context)
      }
      else{
        //      Get the data from the user defaults to display
        let userDefaults = UserDefaults(suiteName: <YOUR APP GROUP>)
        let title = userDefaults?.string(forKey: "headline_title") ?? "No Title Set"
        let description = userDefaults?.string(forKey: "headline_description") ?? "No Description Set"
        entry = NewsArticleEntry(date: Date(), title: title, description: description)
      }
        completion(entry)
    }

//    getTimeline is called for the current and optionally future times to update the widget
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
//      This just uses the snapshot function you defined earlier
      getSnapshot(in: context) { (entry) in
// atEnd policy tells widgetkit to request a new entry after the date has passed
        let timeline = Timeline(entries: [entry], policy: .atEnd)
                  completion(timeline)
              }
    }
}

上述代码中的 Provider 符合 TimelineProviderProvider 有三种不同的方法:

  1. 当用户首次预览主屏幕 widget 时,placeholder 方法会生成占位符条目。

45a0f64240c12efe

  1. getSnapshot 方法从用户默认值中读取数据,并针对当前时间生成条目。
  2. getTimeline 方法会返回时间轴条目。这有助于您把握可预测的时间点来更新内容。此 Codelab 使用 getSnapshot 函数获取当前状态。.atEnd 方法会告知主屏幕 widget 在当前时间过后刷新数据。

注释掉NewsWidgets_Previews

使用预览不在本 Codelab 的讨论范围内。如需详细了解如何预览 SwiftUI 主屏幕 widget,请参阅 Apple 关于调试 widget 的文档

保存所有文件并重新运行应用和微件目标。

再次运行目标以验证应用和主屏幕 widget 是否正常运行。

  1. 在 Xcode 中选择应用架构以运行应用目标。
  2. 在 Xcode 中选择扩展程序架构以运行扩展程序目标。
  3. 导航到应用中的文章页面。
  4. 点击按钮即可更新标题。主屏幕 widget 还应更新标题。

更新 Android 代码

添加主屏幕 widget XML。

在 Android Studio 中,更新上一步中生成的文件。打开 res/layout/news_widget.xml 文件。它定义了主屏幕 widget 的结构和布局。选择右上角的 Code,然后将该文件的内容替换为以下代码:

android/app/res/layout/news_widget.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/widget_container"
   style="@style/Widget.Android.AppWidget.Container"
   android:layout_width="wrap_content"
   android:layout_height="match_parent"
   android:background="@android:color/white"
   android:theme="@style/Theme.Android.AppWidgetContainer">
   
   <TextView
       android:id="@+id/headline_title"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="20sp"
       android:textStyle="bold" />

   <TextView
       android:id="@+id/headline_description"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_below="@+id/headline_title"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:layout_marginTop="4dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="16sp" />

</RelativeLayout>

此 XML 定义了两个文本视图,一个用于文章标题,另一个用于文章说明。这些文本视图还定义了样式。在本 Codelab 中,您将回到此文件。

更新 NewsWidget 功能

打开 NewsWidget.kt Kotlin 源代码文件。此文件包含一个名为 NewsWidget 的生成类,该类扩展了 AppWidgetProvider 类。

NewsWidget 类包含其父类中的三个方法。您将修改 onUpdate 方法。Android 会以固定的时间间隔为 widget 调用此方法。

NewsWidget.kt 文件的内容替换为以下代码:

android/app/java/com.mydomain.homescreen_widgets/NewsWidget.kt

// Import will depend on App ID.
package com.mydomain.homescreen_widgets

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews

// New import.
import es.antonborri.home_widget.HomeWidgetPlugin


/**
 * Implementation of App Widget functionality.
 */
class NewsWidget : AppWidgetProvider() {
    override fun onUpdate(
            context: Context,
            appWidgetManager: AppWidgetManager,
            appWidgetIds: IntArray,
    ) {
        for (appWidgetId in appWidgetIds) {
            // Get reference to SharedPreferences
            val widgetData = HomeWidgetPlugin.getData(context)
            val views = RemoteViews(context.packageName, R.layout.news_widget).apply {

                val title = widgetData.getString("headline_title", null)
                setTextViewText(R.id.headline_title, title ?: "No title set")

                val description = widgetData.getString("headline_description", null)
                setTextViewText(R.id.headline_description, description ?: "No description set")
            }

            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }
}

现在,调用 onUpdate 时,Android 会使用 the widgetData.getString() 方法从本地存储空间获取最新值,然后调用 setTextViewText 来更改主屏幕 widget 上显示的文本。

测试更新

测试应用以确保您的主屏幕 widget 更新为新数据。如需更新数据,请使用文章页面上的更新主屏幕 FloatingActionButton。您的主屏幕微件应该会更新为文章标题。

5ce1c9914b43ad79

5. 在 iOS 主屏幕 widget 中使用 Flutter 应用自定义字体

到目前为止,您已将主屏幕 widget 配置为读取 Flutter 应用提供的数据。Flutter 应用包含您可能想在主屏幕 widget 中使用的自定义字体。您可以在 iOS 主屏幕微件中使用自定义字体。Android 设备不支持在主屏幕微件中使用自定义字体。

更新 iOS 代码

Flutter 将其资源存储在 iOS 应用的 mainBundle 中。您可以通过主屏幕微件代码访问此 bundle 中的资源。

在 NewsWidgets.swift 文件的 NewsWidgetsEntryView 结构体中,进行以下更改

创建一个辅助函数,用于获取 Flutter 资源目录的路径

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...

   // New: Add the helper function.
   var bundle: URL {
           let bundle = Bundle.main
           if bundle.bundleURL.pathExtension == "appex" {
               // Peel off two directory levels - MY_APP.app/PlugIns/MY_APP_EXTENSION.appex
               var url = bundle.bundleURL.deletingLastPathComponent().deletingLastPathComponent()
               url.append(component: "Frameworks/App.framework/flutter_assets")
               return url
           }
           return bundle.bundleURL
       }
   ...
}

使用指向自定义字体文件的网址注册字体。

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...

   // New: Register the font.
   init(entry: Provider.Entry){
     self.entry = entry
     CTFontManagerRegisterFontsForURL(bundle.appending(path: "/fonts/Chewy-Regular.ttf") as CFURL, CTFontManagerScope.process, nil)
   }
   ...
}

更新“标题文字”视图以使用自定义字体。

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...


   var body: some View {
    VStack {
      // Update the following line.
      Text(entry.title).font(Font.custom("Chewy", size: 13))
      Text(entry.description)
    }
   }
   ...
}

现在,当您运行主屏幕微件时,它会使用自定义字体作为标题,如下图所示:

93f8b9d767aacfb2.png

6. 将 Flutter widget 渲染为图像

在本部分中,您会将 Flutter 应用中的图表显示为主屏幕 widget。

与主屏幕上显示的文本相比,此微件的挑战性更高。将 Flutter 图表显示为图像要比尝试使用原生界面组件重新创建它要容易得多。

对主屏幕 widget 进行编码,以将 Flutter 图表渲染为 PNG 文件。您的主屏幕微件可以显示该图片。

编写 Dart 代码

在 Dart 端,添加 home_widget 软件包中的 renderFlutterWidget 方法。此方法需要一个 widget、一个文件名和一个键。它会返回 Flutter widget 的图片,并将其保存到共享容器中。在代码中提供图片名称,并确保主屏幕 widget 可以访问容器。key 会将完整的文件路径以字符串的形式保存在设备的本地存储空间中。这样一来,如果 Dart 代码中的名称发生变化,主屏幕 widget 就能找到该文件。

对于此 Codelab,lib/article_screen.dart 文件中的 LineChart 类表示图表。其 build 方法会返回一个 CustomPainter,用于将此图表绘制到屏幕上。

如需实现此功能,请打开 lib/article_screen.dart 文件。导入 home_widget 软件包。接下来,将 _ArticleScreenState 类中的代码替换为以下代码:

lib/article_screen.dart

import 'package:flutter/material.dart';
// New: import the home_widget package.
import 'package:home_widget/home_widget.dart';

import 'home_screen.dart';
import 'news_data.dart';

...

class _ArticleScreenState extends State<ArticleScreen> {
  // New: add this GlobalKey
  final _globalKey = GlobalKey();
  String? imagePath;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.article.title!),
      ),
      // New: add this FloatingActionButton
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () async {
          if (_globalKey.currentContext != null) {
            var path = await HomeWidget.renderFlutterWidget(
              const LineChart(),
              fileName: 'screenshot',
              key: 'filename',
              logicalSize: _globalKey.currentContext!.size,
              pixelRatio:
                  MediaQuery.of(_globalKey.currentContext!).devicePixelRatio,
            );
            setState(() {
              imagePath = path as String?;
            });
          }
          updateHeadline(widget.article);
        },
        label: const Text('Update Homescreen'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16.0),
        children: [
          Text(
            widget.article.description!,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 20.0),
          Text(widget.article.articleText!),
          const SizedBox(height: 20.0),
          Center(
            // New: Add this key
            key: _globalKey,
            child: const LineChart(),
          ),
          const SizedBox(height: 20.0),
          Text(widget.article.articleText!),
        ],
      ),
    );
  }
}

此示例对 _ArticleScreenState 类进行了三项更改。

创建 GlobalKey

GlobalKey 会获取特定 widget 的上下文,获取该 widget 的大小需要此上下文。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   // New: add this GlobalKey
   final _globalKey = GlobalKey();
   ...
}

添加了 imagePath

imagePath 属性存储渲染 Flutter widget 的图片位置。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   ...
   // New: add this imagePath
   String? imagePath;
   ...
}

向 widget 添加要呈现的键

_globalKey 包含渲染到图像的 Flutter widget。在本例中,Flutter widget 是包含 LineChart 的中心。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   ...
   Center(
      // New: Add this key
 key: _globalKey,
 child: const LineChart(),
   ),
   ...
}
  1. 将 widget 保存为图片

当用户点击 floatingActionButton 时,系统会调用 renderFlutterWidget 方法。该方法将生成的 PNG 文件另存为“屏幕截图”复制到共享容器目录该方法还会将图片的完整路径保存为设备存储空间中的文件名键。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   ...
   floatingActionButton: FloatingActionButton.extended(
 onPressed: () async {
   if (_globalKey.currentContext != null) {
     var path = await HomeWidget.renderFlutterWidget(
       LineChart(),
       fileName: 'screenshot',
       key: 'filename',
       logicalSize: _globalKey.currentContext!.size,
       pixelRatio:
         MediaQuery.of(_globalKey.currentContext!).devicePixelRatio,
     );
     setState(() {
        imagePath = path as String?;
     });
    }
  updateHeadline(widget.article);
  },
   ...
}

更新 iOS 代码

对于 iOS,请更新代码以从存储空间获取文件路径,并使用 SwiftUI 将文件显示为图片。

打开 NewsWidgets.swift 文件并进行以下更改:

filenamedisplaySize 添加到 NewsArticleEntry 结构体

filename 属性包含表示图像文件的路径的字符串。displaySize 属性包含用户设备上的主屏幕 widget 的大小。主屏幕 widget 的大小来自 context

ios/NewsWidgets/NewsWidgets.swift

struct NewsArticleEntry: TimelineEntry {
   ...

   // New: add the filename and displaySize.
   let filename: String
   let displaySize: CGSize
}

更新 placeholder 函数

添加占位符 filenamedisplaySize

ios/NewsWidgets/NewsWidgets.swift

func placeholder(in context: Context) -> NewsArticleEntry {
      NewsArticleEntry(date: Date(), title: "Placeholder Title", description: "Placeholder description", filename: "No screenshot available",  displaySize: context.displaySize)
    }

getSnapshot 中从 userDefaults 获取文件名

这会在主屏幕 widget 更新时,将 filename 变量设置为 userDefaults 存储空间中的 filename 值。

ios/NewsWidgets/NewsWidgets.swift

func getSnapshot(
   ...

   let title = userDefaults?.string(forKey: "headline_title") ?? "No Title Set"
   let description = userDefaults?.string(forKey: "headline_description") ?? "No Description Set"
   // New: get fileName from key/value store
   let filename = userDefaults?.string(forKey: "filename") ?? "No screenshot available"
   ...
)

创建显示路径中的图片的 ChartImage

ChartImage 视图会根据 Dart 端生成的文件内容创建图像。在这里,您将尺寸设置为帧的 50%。

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...

   // New: create the ChartImage view
   var ChartImage: some View {
        if let uiImage = UIImage(contentsOfFile: entry.filename) {
            let image = Image(uiImage: uiImage)
                .resizable()
                .frame(width: entry.displaySize.height*0.5, height: entry.displaySize.height*0.5, alignment: .center)
            return AnyView(image)
        }
        print("The image file could not be loaded")
        return AnyView(EmptyView())
    }
   ...
}

NewsWidgetsEntryView 的正文中使用 ChartImage

将 ChartImage 视图添加到 NewsWidgetsEntryView 的正文,以便在主屏幕 widget 中显示 ChartImage。

ios/NewsWidgets/NewsWidgets.swift

VStack {
   Text(entry.title).font(Font.custom("Chewy", size: 13))
   Text(entry.description).font(.system(size: 12)).padding(10)
   // New: add the ChartImage to the NewsWidgetEntryView
   ChartImage
}

测试更改

如需测试更改,请从 Xcode 重新运行您的 Flutter 应用 (Runner) 目标和扩展程序目标。若要查看图片,请导航至应用中的某篇文章页面,然后按按钮更新主屏幕微件。

33bdfe2cce908c48

更新 Android 代码

Android 代码的运作方式与 iOS 代码相同。

  1. 打开 android/app/res/layout/news_widget.xml 文件。它包含主屏幕 widget 的界面元素。将其内容替换为以下代码:

android/app/res/layout/news_widget.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/widget_container"
   style="@style/Widget.Android.AppWidget.Container"
   android:layout_width="wrap_content"
   android:layout_height="match_parent"
   android:background="@android:color/white"
   android:theme="@style/Theme.Android.AppWidgetContainer">

   <TextView
       android:id="@+id/headline_title"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="20sp"
       android:textStyle="bold" />

   <TextView
       android:id="@+id/headline_description"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_below="@+id/headline_title"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:layout_marginTop="4dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="16sp" />
   
   <!--New: add this image view -->
   <ImageView
       android:id="@+id/widget_image"
       android:layout_width="200dp"
       android:layout_height="200dp"
       android:layout_below="@+id/headline_description"
       android:layout_alignBottom="@+id/headline_title"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:layout_marginTop="6dp"
       android:layout_marginBottom="-134dp"
       android:layout_weight="1"
       android:adjustViewBounds="true"
       android:background="@android:color/white"
       android:scaleType="fitCenter"
       android:src="@android:drawable/star_big_on"
       android:visibility="visible"
       tools:visibility="visible" />

</RelativeLayout>

这个新代码会向主屏幕 widget 添加一张图片,该图片(目前)会显示一个通用星形图标。将此星形图标替换为您在 Dart 代码中保存的图片。

  1. 打开 NewsWidget.kt 文件。将其内容替换为以下代码:

android/app/java/com.mydomain.homescreen_widgets/NewsWidget.kt

// Import will depend on App ID.
package com.mydomain.homescreen_widgets

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.widget.RemoteViews
import java.io.File
import es.antonborri.home_widget.HomeWidgetPlugin


/**
 * Implementation of App Widget functionality.
 */
class NewsWidget : AppWidgetProvider() {
    override fun onUpdate(
            context: Context,
            appWidgetManager: AppWidgetManager,
            appWidgetIds: IntArray,
    ) {
        for (appWidgetId in appWidgetIds) {
            val widgetData = HomeWidgetPlugin.getData(context)
            val views = RemoteViews(context.packageName, R.layout.news_widget).apply {

                val title = widgetData.getString("headline_title", null)
                setTextViewText(R.id.headline_title, title ?: "No title set")

                val description = widgetData.getString("headline_description", null)
                setTextViewText(R.id.headline_description, description ?: "No description set")

                // New: Add the section below
               // Get chart image and put it in the widget, if it exists
                val imageName = widgetData.getString("filename", null)
                val imageFile = File(imageName)
                val imageExists = imageFile.exists()
                if (imageExists) {
                    val myBitmap: Bitmap = BitmapFactory.decodeFile(imageFile.absolutePath)
                    setImageViewBitmap(R.id.widget_image, myBitmap)
                } else {
                    println("image not found!, looked @: ${imageName}")
                }
                // End new code
            }

            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }
}

此 Dart 代码使用 filename 键将屏幕截图保存到本地存储空间。此外,该方法还会获取图片的完整路径,并根据该路径创建 File 对象。如果图片存在,Dart 代码会将主屏幕 widget 中的图片替换为新图片。

  1. 重新加载应用并转到文章屏幕。按更新主屏幕。主屏幕 widget 显示图表。

7. 后续步骤

恭喜!

恭喜,您已成功为 Flutter iOS 和 Android 应用创建了主屏幕微件!

链接到 Flutter 应用中的内容

您可能希望根据用户点击的位置将用户定向到应用中的特定页面。例如,在此 Codelab 的新闻应用中,您可能希望用户看到显示标题的新闻报道。

此功能不在此 Codelab 的讨论范围内。您可以找到相关示例,了解如何使用 home_widget 软件包提供的流来识别应用从主屏幕 widget 启动,并通过网址从主屏幕 widget 发送消息。如需了解详情,请参阅 docs.flutter.dev 上的深层链接文档

在后台更新 widget

在此 Codelab 中,您使用按钮触发了主屏幕 widget 的更新。虽然这对于测试来说是合理的,但在正式版代码中,您可能希望应用在后台更新主屏幕 widget。您可以使用 workmanager 插件创建后台任务,以更新主屏幕 widget 所需的资源。如需了解详情,请参阅 home_widget 软件包中的后台更新部分。

对于 iOS,您还可以让主屏幕微件发出网络请求以更新其界面。如需控制该请求的条件或频率,请使用时间轴。要了解更多关于使用时间轴的信息,请参阅 Apple 的“保持小工具最新”文档。

深入阅读