Skip to content

Latest commit

 

History

History
271 lines (219 loc) · 10.4 KB

README.md

File metadata and controls

271 lines (219 loc) · 10.4 KB

Remote Flutter Widgets

This package provides a mechanism for rendering widgets based on declarative UI descriptions that can be obtained at runtime.

Status

This package was created without a clear idea of what problem it solves, in order to see if it was interesting to people using Flutter and to learn more about the problem space.

So far it has received only minimal feedback, which either means it's perfectly addressing the needs that people have from it or that it is completely out of touch with what people want. We would love to know which, so if you consider this package, please describe your experiences, positive or negative, on issue 90218. This will help us determine whether to spend more effort on this package, or whether we should look at creating other packages.

We plan to keep the format and supported widget set backwards compatible, so that once a file works, it will keep working. However, this is best-effort only. To guarantee that files keep working as you expect, submit tests to this package (e.g. the binary file and the corresponding screenshot, as a golden test).

Getting started

A Flutter application can render remote widgets using the RemoteWidget widget, as in the following snippet:

// see example/hello

class Example extends StatefulWidget {
  const Example({Key? key}) : super(key: key);

  @override
  State<Example> createState() => _ExampleState();
}

class _ExampleState extends State<Example> {
  final Runtime _runtime = Runtime();
  final DynamicContent _data = DynamicContent();

  // Normally this would be obtained dynamically, but for this example
  // we hard-code the "remote" widgets into the app.
  //
  // Also, normally we would decode this with [decodeLibraryBlob] rather than
  // parsing the text version using [parseLibraryFile]. However, to make it
  // easier to demo, this uses the slower text format.
  static final RemoteWidgetLibrary _remoteWidgets = parseLibraryFile('''
    // The "import" keyword is used to specify dependencies, in this case,
    // the built-in widgets that are added by initState below.
    import core.widgets;
    // The "widget" keyword is used to define a new widget constructor.
    // The "root" widget is specified as the one to render in the build
    // method below.
    widget root = Container(
      color: 0xFF002211,
      child: Center(
        child: Text(text: ["Hello, ", data.greet.name, "!"], textDirection: "ltr"),
      ),
    );
  ''');
  
  @override
  void initState() {
    super.initState();
    _runtime.update(const LibraryName(<String>['core', 'widgets']), createCoreWidgets());
    _runtime.update(const LibraryName(<String>['main']), _remoteWidgets);
    _data.update('greet', <String, Object>{ 'name': 'World' });
  }
  
  @override
  Widget build(BuildContext context) {
    return RemoteWidget(
      runtime: _runtime,
      data: _data,
      widget: const FullyQualifiedWidgetName(LibraryName(<String>['main']), 'root'),
      onEvent: (String name, DynamicMap arguments) {
        // The example above does not have any way to trigger events, but if it
        // did, they would result in this callback being invoked.
        debugPrint('user triggered event "$name" with data: $arguments');
      },
    );
  }
}

In this example, the "remote" widgets are hard-coded into the application.

Usage

In typical usage, the remote widgets come from a server at runtime, either through HTTP or some other network transport. Separately, the DynamicContent data is updated, either from the server or based on local data.

It is recommended that servers send binary data, decoded using decodeLibraryBlob and decodeDataBlob, when providing updates for the remote widget libraries and data.

Events (onEvent) are signalled by the user's interactions with the remote widgets. The client is responsible for handling them, either by sending the data to the server for the server to update the data, or directly, on the user's device.

Limitations

Once you realize that you can ship UI (and maybe logic, e.g. using Wasm; see the example below) you will slowly be tempted to move your whole application to this model.

This won't work.

Flutter proper lets you create widgets for compelling UIs with gestures and animations and so forth. With RFW you can use those widgets, but it doesn't let you create those widgets.

For example, you don't want to use RFW to create a UI that involves page transitions. You don't want to use RFW to create new widgets that involve drag and drop. You don't want to use RFW to create widgets that involve custom painters.

Rather, RFW is best suited for interfaces made out of prebuilt components. For example, a database front-end could use this to describe bespoke UIs for editing different types of objects in the database. Message-of-the-day announcements could be built using this mechanism. Search interfaces could use this mechanism for rich result cards.

RFW is well-suited for describing custom UIs from a potentially infinite set of UIs that could not possibly have been known when the application was created. On the other hand, updating the application's look and feel, changing how navigation works in an application, or adding new features, are all changes that are best made in Flutter itself, creating a new application and shipping that through normal update channels.

Developing new local widget libraries

A "local" widget library is one that describes the built-in widgets that your "remote" widgets are built out of. The RFW package comes with some preprepared libraries, available through [createCoreWidgets] and [createMaterialWidgets]. You can also create your own.

When developing new local widget libraries, it is convenient to hook into the reassemble method to update the local widgets. That way, changes can be seen in real time when hot reloading.

// see example/local

class Example extends StatefulWidget {
  const Example({Key? key}) : super(key: key);

  @override
  State<Example> createState() => _ExampleState();
}

class _ExampleState extends State<Example> {
  final Runtime _runtime = Runtime();
  final DynamicContent _data = DynamicContent();
  
  @override
  void initState() {
    super.initState();
    _update();
  }

  @override
  void reassemble() {
    super.reassemble();
    _update();
  }

  static WidgetLibrary _createLocalWidgets() => LocalWidgetLibrary(<String, LocalWidgetBuilder>{
    'GreenBox': (BuildContext context, DataSource source) {
      return Container(color: const Color(0xFF002211), child: source.child(<Object>['child']));
    },
    'Hello': (BuildContext context, DataSource source) {
      return Center(child: Text('Hello, ${source.v<String>(<Object>["name"])}!', textDirection: TextDirection.ltr));
    },
  });


  void _update() {
    _runtime.update(const LibraryName(<String>['local']), _createLocalWidgets());
    _runtime.update(const LibraryName(<String>['remote']), parseLibraryFile('''
      import local;
      widget root = GreenBox(
        child: Hello(name: "World"),
      );
    '''));
  }

  @override
  Widget build(BuildContext context) {
    return RemoteWidget(
      runtime: _runtime,
      data: _data,
      widget: const FullyQualifiedWidgetName(LibraryName(<String>['remote']), 'root'),
      onEvent: (String name, DynamicMap arguments) {
        debugPrint('user triggered event "$name" with data: $arguments');
      },
    );
  }
}

Fetching remote widget libraries remotely

The example in example/remote shows how a program could fetch different user interfaces at runtime. In this example, the interface used on startup is the one last cached locally. Each time the program is run, after displaying the currently-cached interface, the application fetches a new interface over the network, overwriting the one in the cache, so that a different interface is used the next time the app is run.

This example also shows how an application can implement custom local code for events; in this case, incrementing a counter (both of the "remote" widgets are just different ways of implementing a counter).

Integrating with scripting language runtimes

The example in example/wasm shows how a program could fetch logic in addition to UI, in this case using Wasm compiled from C (and let us briefly appreciate the absurdity of using C as a scripting language for an application written in Dart).

In this example, as written, the Dart client could support any application whose data model consisted of a single integer and whose logic could be expressed in C without external dependencies.

This example could be extended to have the C program export data in the Remote Flutter Widgets binary data blob format which could be parsed using decodeDataBlob and passed to DynamicContent.update (thus allowing any structured data supported by RFW), and similarly arguments could be passed to the Wasm code using the same format (encoding using encodeDataBlob) to allow arbitrary structured data to be communicated from the interface to the Wasm logic. In addition, the Wasm logic could be provided with WASI interface bindings or with custom bindings that expose platform capabilities (e.g. from Flutter plugins), greatly extending the scope of what could be implemented in the Wasm logic.

As of the time of writing, package:wasm does not support Android, iOS, or web, so this demo is limited to desktop environments. The underlying Wasmer runtime supports Android and iOS already, and obviously Wasm in general is supported by web browsers, so it is expected that these limitations are only temporary (modulo policy concerns on iOS, anyway).

Contributing

If you run into any problems, please file a new bug, though as noted above, you may have to fix the issue yourself and submit a PR. See our contributing guide for details.

Adding more widgets to lib/flutter/core_widgets.dart and lib/flutter/material_widgets.dart is welcome.

When contributing code, ensure that flutter test --coverage; lcov --list coverage/lcov.info continues to show 100% test coverage, and update test_coverage/bin/test_coverage.dart with the appropriate expectations to prevent future coverage regressions. (That program is run by run_tests.sh.)

Golden tests are only run against the Flutter master channel and only run on Linux, since minor rendering differences are expected on different platforms and on different versions of Flutter.