Stager

Stager 是一个 Flutter 开发工具,它允许您将应用程序的小部分作为独立的 Flutter 应用运行。这使您可以

  • 将开发重点放在单个 widget 或流程上——无需点击多个屏幕或设置外部功能标志即可到达您正在处理的页面。
  • 确保您的 UI 在多种情况下都能正常工作,包括
    • 浅色和深色模式
    • 小或大的文本大小
    • 不同的视口尺寸
    • 不同的设备类型
    • 加载、空、错误和正常状态
  • 将所有这些展示给您的设计师,以确保您的应用程序像素完美。

演示

example app demo

此存储库中包含的示例演示了如何在类似 Twitter 的简单应用程序的上下文中以及包含帖子详情页和用户详情页的情况下使用 Stager。

Stager 使用您定义的 Scenes(参见下面的“概念”部分)来生成小的 Flutter 应用。要运行示例中包含的 Stager 应用,请首先移至 `example` 目录并获取应用程序的依赖项

cd example
flutter pub get

注意:示例的 Stager `main` 文件已生成。要从包含 Scenes 的文件生成 Stager `main` 文件,请从您的应用根目录运行 `flutter run build_runner`。

然后,您可以使用以下命令运行各个 Stager 应用

帖子列表

flutter run -t lib/pages/posts_list/posts_list_page_scenes.stager_app.g.dart

用户详情

flutter run -t lib/pages/user_detail/user_detail_page_scenes.stager_app.g.dart

帖子详情

flutter run -t lib/pages/post_detail/post_detail_page_scenes.stager_app.g.dart

为了了解这些 Scenes 如何协同工作,您还可以通过在 `example` 目录中执行 `flutter run` 来运行主应用程序,该命令运行默认的 `main.dart`。

概念

Scene

Scene 是一个简单、自给自足的 UI 单元,是 Stager 中最重要的概念。Scenes 使专注于单个 widget 或页面变得容易,通过将其与应用程序的其余部分隔离开来,极大地提高了开发速度。这种隔离使得为您的 UI 提供各种输入以及用模拟或备用实现替换依赖项变得更加容易。

要创建自己的 Scene,只需创建一个 `Scene` 子类并实现 `title`(Scene 的名称)和 `build()`(构建 Scene 的主体)即可。

您还可以覆盖以下方法和属性

setUp

在 Scene 显示之前调用一次的函数。这通常是配置 widget 依赖项的地方。

environmentControls

一个可选的 `EnvironmentControl` 列表,允许您将自定义 widget 添加到 Stager 控制面板。`EnvironmentControl` 提供了一个 widget,允许用户更改显示 Scene 时使用的值。当多个 Scene 使用相同的控件时,状态会得到保留。Stager 包含几个此类控件,允许用户切换深色模式、更改文本比例等。

如果您想操作特定于您的应用程序的内容,这些会很有用,包括

  • widget 显示的数据
  • 模拟依赖项上的属性
  • 功能标志

StagerApp

StagerApp 显示一个 Scene 列表,允许用户从所有可用的 Scenes 中进行选择。由于 Scenes 可以包含自己的 Navigator,因此 StagerApp 会在 Scenes 之上叠加一个后退按钮。

您通常不需要直接与此类交互——Stager 会为您生成此类。编写完 Scene 类后,只需从项目根目录运行 `flutter run build_runner` 即可生成包含 `main()` 入口点的文件,该文件会创建一个带有您的 Scenes 的 StagerApp。

使用

想象一下,您的应用程序深处有以下 widget

/// A [ListView] of [PostCard]s
class PostsList extends StatefulWidget {
  /// Creates a [PostsList] displaying [posts].
  ///
  /// [postsFuture] will be set to the value of [posts].
  PostsList({
    Key? key,
    required List<Post> posts,
  }) : this.fromFuture(key: key, Future<List<Post>>.value(posts));

  /// Creates a [PostsList] with a Future that resolves to a list of [Post]s.
  const PostsList.fromFuture(this.postsFuture, {super.key});

  /// The Future that resolves to the list of [Post]s this widget will display.
  final Future<List<Post>> postsFuture;

  @override
  State<PostsList> createState() => _PostsListState();
}

class _PostsListState extends State<PostsList> {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Post>>(
      future: widget.postsFuture,
      builder: (BuildContext context, AsyncSnapshot<List<Post>> snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        if (snapshot.hasError) {
          return const Center(
            child: Text('Error'),
          );
        }

        final List<Post>? posts = snapshot.data;
        if (posts == null || posts.isEmpty) {
          return const Center(
            child: Text('No posts'),
          );
        }

        return ListView.builder(
          itemBuilder: (BuildContext context, int index) => PostCard(
            post: posts[index],
            onTap: () {
              Navigator.of(context).push(
                MaterialPageRoute<void>(
                  builder: (BuildContext context) => PostDetailPage(
                    post: posts[index],
                  ),
                ),
              );
            },
          ),
          itemCount: posts.length,
        );
      },
    );
  }
}

通常,要执行此 widget 的所有状态,需要

  1. 构建并启动完整的应用程序。
  2. 导航到此页面。
  3. 编辑代码以强制显示我们要执行的状态,方法是构建一个假的 `Future<List<Post>>` 或注释掉 FutureBuilder 的 `builder` 函数中的各种条件检查。

Scenes 提供了一种更好的方法来做到这一点。

创建 Scene

我们可以为要显示的每种状态创建一个 Scene。例如,一个显示 PostsListPage 空状态的 Scene 可能如下所示

@GenerateMocks(<Type>[Api])
import 'posts_list_page_scenes.mocks.dart';

/// Defines a shared build method used by subclasses and a [MockApi] subclasses
/// can use to control the behavior of the [PostsListPage].
abstract class BasePostsListScene extends Scene {
  /// A mock dependency of [PostsListPage]. Mock the value of [Api.fetchPosts]
  /// to put the staged [PostsListPage] into different states.
  late MockApi mockApi;

  @override
  Widget build() {
    return EnvironmentAwareApp(
      home: Provider<Api>.value(
        value: mockApi,
        child: const PostsListPage(),
      ),
    );
  }

  @override
  Future<void> setUp() async {
    mockApi = MockApi();
  }
}

/// A Scene showing the [PostsListPage] with no [Post]s.
class EmptyListScene extends BasePostsListScene {
  @override
  String get title => 'Empty List';

  @override
  Future<void> setUp() async {
    await super.setUp();
    when(mockApi.fetchPosts()).thenAnswer((_) async => <Post>[]);
  }
}

运行 StagerApp

创建 Scene 子类后,生成您的 `StagerApp`

flutter pub run build_runner build --delete-conflicting-outputs

这将生成一个 `my_scenes.stager_app.g.dart` 文件(如果包含 Scenes 的文件名为 `my_scenes.dart`),其中包含一个 `main` 函数,该函数创建您的 Scenes 并启动 StagerApp。对于我们上面定义的 `EmptyListScene`,它看起来会像

// GENERATED CODE - DO NOT MODIFY BY HAND

// **************************************************************************
// StagerAppGenerator
// **************************************************************************

import 'package:stager/stager.dart';

import 'posts_list_page_scenes.dart';

void main() {
  final List<Scene> scenes = <Scene>[
    EmptyListScene(),
  ];

  if (const String.fromEnvironment('Scene').isNotEmpty) {
    const String sceneName = String.fromEnvironment('Scene');
    final Scene scene =
        scenes.firstWhere((Scene scene) => scene.title == sceneName);
    runStagerApp(scenes: <Scene>[scene]);
  } else {
    runStagerApp(scenes: scenes);
  }
}

您可以直接从 VS Code 启动此应用程序,或者通过运行

flutter run -t path/to/my_scenes.stager_app.g.dart

如果您的 Stager 应用包含多个 Scenes,您可以通过提供场景名称作为参数来启动到特定场景

flutter run -t path/to/my_scenes.stager_app.g.dart --dart-define='Scene=No Posts'

添加自己的环境控件

Stager 的控制面板附带了一组普遍有用的控件,可让您切换深色模式、调整文本比例等。但是,您的应用程序很可能具有独特的环境属性,在运行时进行调整会很有用。为此,Scenes 具有一个可覆盖的 `environmentControls` 属性,该属性允许您将自定义 widget 添加到默认的环境操作控件集中。

一个非常简单的例子

class CounterScene extends Scene {
  // A [StepperControl] allows the user to increment and decrement a value using "-" and
  // "+" buttons. [EnvironmentControl]s will trigger a Scene rebuild when they update
  // their values.
  final StepperControl<int> stepperControl = StepperControl<int>(
    title: 'My Control',
    stateKey: 'MyControl.Key',
    defaultValue: 0,
    onDecrementPressed: (int currentValue) => currentValue + 1,
    onIncrementPressed: (int currentValue) => currentValue - 1,
  );

  @override
  String get title => 'Counter';

  @override
  final List<EnvironmentControl<Object?>> environmentControls =
      <EnvironmentControl<Object?>>[
        stepperControl,
  ];

  @override
  Widget build() {
    return EnvironmentAwareApp(
      home: Scaffold(
        body: Center(
          child: Text(stepperControl.currentValue.toString()),
        ),
      ),
    );
 }
}

更复杂的示例可以在 `example/lib/pages/posts_list/posts_list_page_scenes.dart` 中的 `WithPostsScene` 和 `example/lib/pages/post_detail/post_detail_page_scenes.dart` 中的 `PostDetailPageScene` 中找到。

测试

您可能会注意到这些名称与 Flutter 测试函数非常相似。这是故意的——Scenes 非常容易在测试中重复使用。为您的 widget 编写 Scenes 可以是开始编写 widget 测试或扩展 widget 测试覆盖率的好方法。使用 Scene 的 widget 测试可以很简单,如下所示

testWidgets('shows an empty state', (WidgetTester tester) async {
  final Scene scene = EmptyListScene();
  await scene.setUp();
  await tester.pumpWidget(scene.build());
  await tester.pump();
  expect(find.text('No posts'), findsOneWidget);
});

GitHub

查看 Github