DEV Community

Code on the Rocks
Code on the Rocks

Posted on • Originally published at codeontherocks.dev

Flutter Inception: A Free Shorebird Alternative

There is an intimacy between web developers and their users that native app developers are jealous of. Within minutes of finding and diagnosing a software bug, web developers can publish a fix that all of their users will see. There is no laborious back-and-forth with a third-party app store or a 4-day wait for an simple app review. The web developer builds their new application and sends it to the masses.

Native app developers are not so lucky. App review times on the Google Play and Apple App stores are infamously unpredictable and long, delaying what could have otherwise been immediate fixes for an indeterminate amount of time. This slow but necessary step can ultimately lead to poor reviews, lost users, and developer frustration.

Flutter developers in particular are negatively impacted by these review process because they typically aim to publish their apps on all platforms. Every time they are ready to release an update, they must ask themselves, "Am I ready for two app reviews?". Many other cross-platform developers face the same soul-crushing processes but in this article, I want to present a potential solution for the Flutter crowd.

On the Web and in your Pocket

Flutter is special among cross-platform frameworks because it works on Android, iOS, and the web with very little modification. In fact, every Flutter web application can be downloaded as a progressive web app (PWA) that performs similarly to a native application. In my opinion, PWAs are a valid solution to the app review problem because they
allows developers to publish updates at their own pace without sacrificing (too much of) the native experience. The issue with this approach, of course, is that a majority of people don't know how to download a PWA and among those that do, the willingness to do so is low.

Forget the PWA experience, then. Even if users don't download your Flutter app as a PWA, they can still use it in their browser which allows them the benefit of real-time updates. While good, this solution is not perfect either because native apps are still more performant and the app stores
provide a level of trust that the wild web does not.

If not a PWA or web app, then what? Well, what if we could have both? What if we could have the native app experience 95% of the time and the web experience the other 5%? What if we could have our cake and eat it too? That would be nice.

The final solution I propose is to ship your native apps with a webview fallback. When the latest version of your app has passed app store review, users will get the native experience. When the latest version of your app is still in review, users will get the web experience. From a user perspective, the difference should be subtle to the point of being unnoticeable which is leaps and bounds better than them seeing a bug. Whether or not the webview is used can be
determined by a simple API call to your server, a Firebase Remote Config value, or an automated process that compares the current native version to the latest web version. The rest of this article will discuss the setup in more details.

Image description

Deployment Approach

My personal suggestion is to take a web-first approach to app development. Web applications are faster to update, globally accessible, and many browsers include a mobile device emulator that makes testing the responsiveness of your app as easy as resizing your browser window. By making your application welcoming to web travelers, you will make it welcoming to all users, regardless of the color of their SMS messages.

A specific suggestion would be to develop and test your application on a chrome browser using the built in dev tools to emulate how it would look on a mobile device. When everything looks good to go, ship it and start testing on native platforms. Taking this approach will ultimately lead to a drastically higher number of web deployments compared to Android or iOS deployments but that's fine. If you're marketing campaigns are web-focused, most of your traffic will be browser-bound anyway.

Code

Web Application

The first step is to create a web application that will be used as the fallback for your native app. This application should be hosted on a server that you control and should be accessible via a URL. The URL can be stored in your app as a global variable or passed to your application using dart-define. I recommend using a Firebase Hosting since its super easy to setup. Develop this application as you would any other Flutter application and publish it.

In App Webview

To effectively implement this solution, you'll need a trusty webview package that works on both Android and iOS platforms. I prefer to use the flutter_inappwebview package which you can add to your pubspec.yaml using the following line:

flutter_inappwebview: ^5.7.2+3
Enter fullscreen mode Exit fullscreen mode

There is a beta version of this plugin that also works on the web

Once you've added the dependency, the next step is to create an app_webview widget with the sole purpose of displaying your web application in a webview. A small visual indicator, like a different colored status bar when the app is using the
webview, can help you avoid being confused during development. The bare bones code looks like this:

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

  @override
  State<AppWebview> createState() => _AppWebviewState();
}

class _AppWebviewState extends State<AppWebview> {
  final GlobalKey webViewKey = GlobalKey();

  InAppWebViewController? webViewController;

  @override
  Widget build(BuildContext context) {
    return 
      Scaffold(
        appBar: AppBar(
          toolbarHeight: 0,
          backgroundColor: Colors.black,
        ),
        body: InAppWebView(
          key: webViewKey,
          pullToRefreshController: pullToRefreshController,
          initialUrlRequest: URLRequest(url: WebUri(const String.fromEnvironment('APP_WEBVIEW_BASE_URL'))),
          onWebViewCreated: (controller) {
            webViewController = controller;
          },
        ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The webview variant of your native app doesn't need to use the router your app normally uses (ex.
auto_route, go_router, etc) so you can add it directly to a MaterialApp widget. The app inside
the webview will use the router as designed.

The biggest problem with this code is that your native application will think it only has a single route. In other words, the native Android and iOS apps can't see the router in the webview. One tap of the Android back button or a single right swipe on iOS will close the application completely. We don't want that.

To fix this, we need to wrap the webview widget in a WillPopScope widget that will detect back
button presses and communicate them to the webview controller. If the webview can navigate backwards in its history, a back button press should do that instead of closing the app:

return WillPopScope(
      onWillPop: () async {
        final controller = webViewController;
        if (controller != null) {
          if (await controller.canGoBack()) {
            controller.goBack();
            return false;
          }
        }
        return false;
      },
      child: Scaffold(
        appBar: AppBar(
          toolbarHeight: 0,
          backgroundColor: Colors.black,
        ),
        body: // ...webview code
      ),
    );
Enter fullscreen mode Exit fullscreen mode

When to Webview

Now that we have a webview set up, we need to decide when to use that instead of the normal app. This part is up to you but I'll provide a few ideas here. Since the app stores operate at different speeds, its a good idea to create separate flags for Android and iOS.

Firebase Remote Config

Firebase Remote Config lets you create and update feature flags easily through your project's
Firebase console. On the Remote Config tab, add two new boolean parameters (android_webview and
ios_webview), set them to false, and publish the changes. When a platform should use the webview,
change its corresponding flag to true and publish.

Image description

In the app, you can read these values using the firebase_remote_config package. The following code sets up the remote config and
reads the values for the android_webview and ios_webview flags:

final remoteConfig = FirebaseRemoteConfig.instance;

Future<void> setup() async {
  await remoteConfig.setConfigSettings(RemoteConfigSettings(
    fetchTimeout: const Duration(minutes: 1),
    minimumFetchInterval: const Duration(hours: 1),
  ));

  await remoteConfig.setDefaults({
    'android_webview': false,
    'ios_webview': false,
  });

  await remoteConfig.fetchAndActivate();
}

bool get androidWebview => remoteConfig.getBool('android_webview');

bool get iosWebview => remoteConfig.getBool('ios_webview');
Enter fullscreen mode Exit fullscreen mode

Pros:

✅ Fast to change

✅ Fast to deploy

Cons:

❌ Requires Firebase project and packages

❌ Not always immediate

Dart Endpoint

If you're a full stack Dartists, creating a simple endpoint using Dart Frog can be a fast way to
add remote control to your app. Create a new endpoint that returns a boolean value for each
platform. When the endpoint is hit, the app can read the value and decide if it should use the
webview.

Pros:

✅ Fully customizable

✅ Lightweight

Cons:

❌ Requires a new server deploy for each change

❌ More setup

If you'd rather your app automatically detect when it should use the webview, you can tweak this implementation slightly and store the latest version in Remote Config or on your server. Then, when your native apps start up, they can check their own version against this server version and decide if the webview is necessary.

You can get the current version of your app using the package_info_plus package. Add it to your pubspec.yaml:

package_info_plus: ^1.3.0
Enter fullscreen mode Exit fullscreen mode

Then, in your main.dart file, you can get the current version of your app:

final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
Enter fullscreen mode Exit fullscreen mode

Regardless of how you decide to set these flags, the implementation on the frontend will look similar. If the app is running on the web, it should never use the webview. If its running on native Android or iOS, it should check the relevant flag and decide what to do:

class MyApp extends StatelessWidget {
  MyApp({super.key});

  final MaterialApp baseApp = MaterialApp.router(
    routerConfig: router.config(),
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      useMaterial3: true,
    ),
  );

  @override
  Widget build(BuildContext context) {
    return Builder(builder: (context) {
      if (kIsWeb) {
        return baseApp;
      } else {
        bool androidWebview = Platform.isAndroid && remoteConfigService.androidWebview;
        bool iosWebview = Platform.isIOS && remoteConfigService.iosWebview;
        bool showWebView = androidWebview || iosWebview;

        if (!kIsWeb && showWebView) {
          return const MaterialApp(home: AppWebview());
        } else {
          return baseApp;
        }
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

What about the Guidelines?

According to section 2.5.2 of the Apple Developer guidelines:

Apps should be self-contained in their bundles, and may not read or write data outside the
designated container area, nor may they download, install, or execute code which introduces or changes features or functionality of the app, including other apps. Educational apps designed to teach, develop, or allow students to test executable code may, in limited circumstances, download code provided that such code is not used for other purposes. Such apps must make the source code provided by the app completely viewable and editable by the user.

If we're comparing the webview fallback method to Shorebird.dev, I'd argue that the webview approach is more acceptable since the contents of the users app does not change until they want it to (by updating the app through the App Store).

Conclusion

If you've ever accidentally shipped a bug to production, you know what true helplessness feels like. Regardless of how simple the fix is, you can be stuck watching users encounter the bug for hours or even days as stakeholders question you about when it will be squashed. Its a stressful time and stress is bad for your health. With the webview fallback solution I've described here, you might just get days of your life back. Most of the time your users will see the native
experience they're used to. For the other handful of times, when a bug has crept into prod or a much needed update is standing in the TSA line, you can flip a switch and sleep easy. How can you beat that?

Top comments (1)

Collapse
 
etaix profile image
Eric Taix 💙 #flutter

Very interesting solution 👌
I made a POC Yesterday based on your proposal and it works very well except for one feature of our application that is not compatible for now with web. But for sure it's a real alternative of shorebird.
Thanks you 🙏