Building Rich Events in Flutter Without App Updates — Part 2 (feat. RFW)

Building Rich Events in Flutter Without App Updates — Part 2 (feat. RFW)


This is a continuation of Part 1.

Introduction

In the previous post I gave a quick summary of how to build an event page. But can we really get the job done with nothing but a WebView and an observer?

RFW

The RFW (Remote Flutter Widget) package describes itself like this:

a library for rendering declarative widget description files at runtime.

In other words, it’s a library for rendering declarative widget description files at runtime. That means you can add dynamically rendered widgets to a view at runtime.

How I Came Across It

I actually stumbled upon this package while looking for something like CodePush for apps. Heh. Every year Flutter announces what it plans to focus on for the following year and what it does not, which you can check out in the Roadmap. In the Non-goals section, they list what they won’t be working on the next year, and sure enough, it says code push isn’t supported (…). But seeing that other packages did exist, I got curious about what was going on. Shorebird supports the same functionality as code push, but since it’s a third-party package and its iOS support was still a beta release at the time (or so I thought — when I checked again, it had actually reached a stable release…!), I was hesitant to adopt it. So I wondered what RFW was all about.

Limitations

If you only go by the description, it sounds like you could draw every view dynamically and wouldn’t need any code in the app at all. But it’s easier to think of it as a way to deliver some of your UI code from the server. Updating all of your UI or business logic can be difficult.

The official docs also spell out the limitations…

How to Use It

First, add the package to pubspec.yaml. You can find the example code at the following link. (Github)

First off, the information that can go into each property is of type String or num. Let’s look at some example code.

import core.widgets;
import core.material;

widget Counter = Scaffold(
  appBar: AppBar(title: Text(text: "Counter Demo")),
  body: Center(
    child: Column(
      mainAxisAlignment: "center",
      children: [
        Text(text: 'You have pushed the button this many times:', textAlign: "center"),
        Text(text: data.counter, style: {
          fontSize: 36.0,
        }),
      ],
    ),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: event "increment" { },
    tooltip: "Increment",
    child: Icon(icon: 0xE047, fontFamily: "MaterialIcons"),
  ),
);

You can also see the information used by the actual app that the view will be placed in (data.counter). And you can see increment, an event that sends data over to the app side.

Let’s look at another example.

import core.widgets;
import core.material;

widget Counter = Container(
  color: 0xFF66AACC,
  child: Center(
    child: Button(
      child: Padding(
        padding: [ 20.0 ],
        child: Text(text: data.counter, style: {
          fontSize: 56.0,
          color: 0xFF000000,
        }),
      ),
      onPressed: event 'increment' { },
    ),
  ),
);

widget Button { down: false } = GestureDetector(
  onTap: args.onPressed,
  onTapDown: set state.down = true,
  onTapUp: set state.down = false,
  onTapCancel: set state.down = false,
  child: Container(
    duration: 50,
    margin: switch state.down {
      false: [ 0.0, 0.0, 2.0, 2.0 ],
      true: [ 2.0, 2.0, 0.0, 0.0 ],
    },
    padding: [ 12.0, 8.0 ],
    decoration: {
      type: "shape",
      shape: {
        type: "stadium",
        side: { width: 1.0 },
      },
      gradient: {
        type: "linear",
        begin: { x: -0.5, y: -0.25 },
        end: { x: 0.0, y: 0.5 },
        colors: [ 0xFFFFFF99, 0xFFEEDD00 ],
        stops: [ 0.0, 1.0 ],
        tileMode: "mirror",
      },
      shadows: switch state.down {
        false: [ { blurRadius: 4.0, spreadRadius: 0.5, offset: { x: 1.0, y: 1.0, } } ],
        default: [],
      },
    },
    child: DefaultTextStyle(
      style: {
        color: 0xFF000000,
        fontSize: 32.0,
      },
      child: args.child,
    ),
  ),
);

This is a slightly more complex view, and it looks like RFW supports most of the features of the material widgets among the properties you’d actually use in Flutter.

Let’s look at another example.

static WidgetLibrary _createLocalWidgets() {
    return LocalWidgetLibrary(<String, LocalWidgetBuilder>{
      'GreenBox': (BuildContext context, DataSource source) {
        return ColoredBox(
          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(localName, _createLocalWidgets());
    _runtime.update(remoteName, parseLibraryFile('''
      import local;
      widget root = GreenBox(
        child: Hello(name: "World"),
      );
    '''));
  }

This code displays a locally defined widget. Here, if you only specify the file information inside _runtime.update from the server, you’ll be able to target whichever locally declared widget you want.

The RFW View I Built

First, declare the view you want in RFW.

import core.widgets;
import core.material;

widget Counter = GestureDetector(
    child: Container(
        height: 200,
        color: 0xFF002211,
        child: Column(
            mainAxisAlignment: "center",
            crossAxisAlignment: "center",
            children: [Text(text: ["Hello, ", data.greet.name, "!"], textDirection: "ltr"),],
        ),
    ),
    onTap: event "greeting" { data: "GO" },
);

When you tap a variable called Counter via the GestureDetector, it passes the data “GO” to an event called greeting. It also receives the variable value greet.name from the client side.

On the client (app) side, you declare it like this:

// https://github.com/halfmoon-mind/remote-flutter-widget/raw/main/lib/remote/counter_app1.rfw
// https://github.com/halfmoon-mind/remote-flutter-widget/raw/main/lib/remote/counter_app2.rfw

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rfw/rfw.dart';

void main() {
  runApp(
    MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const RemoveView(),
    ),
  );
}

class RemoveView extends StatefulWidget {
  const RemoveView({super.key});

  @override
  State<RemoveView> createState() => _RemoveViewState();
}

class _RemoveViewState extends State<RemoveView> {
  final Runtime _runtime = Runtime();
  final DynamicContent _data = DynamicContent();

  int _counter = 0;
  bool _ready = false;

  @override
  void initState() {
    super.initState();
    _runtime.update(
        const LibraryName(<String>['core', 'widgets']), createCoreWidgets());
    _runtime.update(const LibraryName(<String>['core', 'material']),
        createMaterialWidgets());

    _updateData();
    _updateWidgets();
  }

  void _updateData() {
    _data.update('counter', _counter.toString());
  }

  void _updateWidgets() async {
    final Directory home = await getApplicationSupportDirectory();
    const baseUrl =
        "https://github.com/halfmoon-mind/remote-flutter-widget/raw/main/lib/remote/";
    const firstFileName = "counter_app1.rfw";
    const secondFileName = "counter_app2.rfw";

    String targetFileName = firstFileName;

    // 항상 새로운 값으로 업데이트
    File targetFile = File(join(home.path, firstFileName));
    if (targetFile.existsSync()) {
      targetFile.deleteSync();
      targetFileName = secondFileName;
      targetFile = File(join(home.path, secondFileName));
    }
    final client =
        await (await HttpClient().getUrl(Uri.parse('$baseUrl$targetFileName')))
            .close();
    await targetFile
        .writeAsBytes(await client.expand((element) => element).toList());
    _runtime.update(const LibraryName(<String>['main']),
        decodeLibraryBlob(await targetFile.readAsBytes()));
    setState(() {
      _ready = true;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_ready) {
      return const Material(
        child: Scaffold(
          body: Center(
            child: Text(
              "NOT READY",
              style: TextStyle(fontSize: 40),
            ),
          ),
        ),
      );
    }
    return RemoteWidget(
      runtime: _runtime,
      widget: const FullyQualifiedWidgetName(
        LibraryName(<String>['main']),
        'Counter',
      ),
      data: _data,
      onEvent: (eventName, eventArguments) {
        if (eventName == 'increment') {
          setState(() {
            _counter++;
            _updateData();
          });
        }
        if (eventName == "greeting") {
          Fluttertoast.showToast(msg: "Hello, ${eventArguments['data']}");
        }
      },
    );
  }
}

To keep showing a different screen each time, this code alternately saves files 1 and 2. This code demonstrates passing data from the Flutter side to RFW, sending information from the RFW side to Flutter in the form of an event, and actually showing the screen being drawn dynamically.

Closing Thoughts

First, it was tricky because the syntax differs a bit from real Flutter. Whether it actually works comes down to whether RFW’s parser can properly analyze the code, but because the syntax is slightly different, it wasn’t always obvious how to plug values in.

// 예시
import core.widgets;
import core.material;
widget Counter = Scaffold(
  appBar: AppBar(title: Text(text: "Counter Demo")),
  body: Center(
    child: Column(
      mainAxisAlignment: "center",
      children: [
        Text(text: 'You have pushed the button this many times:', textAlign: "center"),
        Text(text: data.counter, style: {
          fontSize: 36.0,
        }),
      ],
    ),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: event "increment" { },
    tooltip: "Increment",
    child: Icon(icon: 0xE047, fontFamily: "MaterialIcons"),
  ),
);

Looking at the example code, the values that can go into what we think of as a Scaffold differ slightly. Where you used to write something like MainAxisAlignment.center, in RFW you pass it as a String instead, and so on…

Setting aside these inconveniences, it was incredibly groundbreaking. On top of being able to pre-render a placeholder view while data is loading, the biggest advantage seemed to be that you can deliver data dynamically. If needed, you could even have the server process and serve the code directly.

One downside that stands out is that you have to compile the code with a runner. If you look at Github, it shows the encoding approach, and it was a bit of a letdown since it doesn’t seem to be fully dynamic.

I gave a presentation about this at school, and during the Q&A someone asked whether RFW can maintain the same performance as code written in Dart in Flutter, and I couldn’t answer. This is something worth looking into and thinking about.

While digging into this package, I found that there aren’t many articles about it, so I hope this helps anyone interested in it.