[MUSIC PLAYING]
JOHN "CODEFU" MCDOLE: Hi.
I'm CodeFu, a.k.a.
John McDole.
And we're going to talk to you today
about real-world Flutter lessons for
federated plug-in development.
Learn from years of experience developing Stadia's gaming
platform.
Why do we care about this subject?
Stadia's goal is one place for all the ways we play.
Or better put, we want Stadia to be as ubiquitous as YouTube is,
be it on phones, TV screens, desktops,
and all the creative places our users have installed our app.
Reid's and my team have years of experience
working with cutting-edge Flutter features.
We've condensed our experiences into the following lessons
to share with you today--
what is a federated plugin, using protos to communicate
between layers, not using magic strings or numbers
in your code, avoid using platform branching UI logic,
and some "byte-sized tips.
But first, I'll let Reid explain what a federated plugin is.
REID BAKER: Let's start by describing the different areas
of a plugin.
There are three high-level parts, app-facing,
platform interface, and platform specifics.
App-facing is the idiomatic Dart API
service you want your Flutter code to use.
Platform interface is what needs to be implemented
by every platform and is not visible outside of the plugin
implementation.
Platform specifics-- or more verbosely,
platform implementation-- is your code
that implements the platform interface.
This is the code that can depend on and call
platform-specific methods.
This diagram helps with visualization.
As you move down the slide, you have your app-facing API,
any shared business logic, then your platform
interface, with each individual platform implementation
underneath.
We realize that as a plugin builder,
it's our responsibility to think about what we want our Dart
API to be, independently of how that API needs
to be implemented in the platform layer.
For me, this is the aha moment.
Build the API you want.
Then figure out how to implement it.
In this example, we want to expose a future string.
But the value of that string is only
available from some native platform method.
To calculate the value, we'll trigger the evaluation
by using our platform interface.
Then after the computation is done,
our native code will use the platform interface
to trigger onValueCalculated, which completes the completer.
Anyone who calls this plugin does not
need to know that the string comes from a callback or even
what platform API we depend on.
The caller has a Dart method that they
can use like any other.
And our internal use of completers
lets us abstract away that implementation detail.
We learned a lot after developing
our first few plugins.
Let me hand it off to CodeFu to share
some more of those lessons.
JOHN "CODEFU" MCDOLE: Thanks, Reid.
One of the lessons our team learned early on--
when sending messages across a boundary,
you should use proto buffs to handle the essentials
of encoding and decoding.
Protocol buffers are Google's language-neutral,
platform-neutral extensible mechanism
for serializing structured data.
Think of XML, but smaller, faster, and simpler.
You can learn more at the following link.
In this example, we map our hypothetical streaming plugin
communications with a simple proto having a start_
and stop_game call messages.
Each plugin message encodes exactly one plugin message
we wish to send over, say, a method channel.
We do not expose these protos to our users of our plugin.
They are simply serializing communication
between Dart and a native code.
On both sides of the channel, we don't
need to concern ourselves with the order or type conversion.
We run a simple protoc command to cogen Dart files
from our proto buff.
As an added benefit, when following best practices,
proto buffs help with things like version skew
and backwards compatibility.
Their use can lead to a longer, happier developer life,
even when not dealing specifically with plugins--
for example, when writing configuration files
to storage in an older app and then reading them
in a newer app.
If parts of your app, however, are shipped independently--
for example, our firmware for decoding video streams--
you'll experience version skew.
Lesson two, avoid magic strings or numbers--
magic strings are strings you end up copying and using
in multiple locations.
In federated plugins, this could end up
being in multiple languages handled by Flutter's method
channels.
If you find yourself copying and pasting
strings, numbers, color codes, or any other basic item,
consider this.
You may occasionally have a typo or missing capitalization--
not something I've ever done.
This is an easy-to-find bug when it's
early in your initialization.
But depending on your plugin complexity and flows,
it could be triggered much later in your app lifecycle
and only be on one platform.
Instead, consider putting all your strings
in, say, one place, a text proto,
and using tooling to cogen your way to safety.
This simple platform constants file could be used by a parser
to read in strings and integers used by multiple platform
implementations.
By defining a simple text proto, we
can write once any number of constants
and have all the magic strings output
cross-language header files, Dart files, Java files,
and more.
This can be hooked up to your build system
to automatically update local files in your plugins.
What's more, auto-generated constants
will give you code completion in your editor.
Or do whatever you like.
We're not trying to be prescriptive with proto buffs.
It's just a tool that we use day in and day out.
Feel free to play around with package source gen and Dart
annotations.
Or use the Pigeon package.
We've discovered that the most expensive form of testing
is integration or manual QA testing.
Proto buffs allow us to unit-test
either side of a plugin and remove
a common source of errors.
I want to make a strong caveat.
Feel free to prototype rapidly as possible
early on when defining a plugin.
But settle on cogen before productionization or working
with other teams to expand your federated reach.
It really depends on your team size and your plugin size.
Another lesson we learned was that there
are many situations where you want your app
to behave differently depending on platform or form factor.
The naive solution to these problems
can lead to hard-to-maintain code.
I'm going to pass this off to Reid to cover that topic.
REID BAKER: Thanks, CodeFu.
Let's first talk about why you might want to branch.
You might branch because some piece of functionality
is only available on one platform.
That could be because of an inherent limitation
on the others or because you're actively developing a feature
and not all platforms are implemented yet.
You could also branch because you
prefer a different experience on a different platform.
The issue we found was that mixing functionality
and preference made code harder to maintain, especially
when something in the system changed.
What if a feature was added or removed
in an updated version of a plugin that we depend on
or the OS added permissions for a feature we already built?
What if we landed functionality one platform at a time?
We need to audit the entire code base and find when we branched
and why for each specific change in functionality.
Months after a feature is built or after the original engineer
has moved on, it can be difficult to understand
why code that checks for the current platform
cares in the first place, especially
if there's no obvious missing functionality.
In this example, downloadFiles depends
on a plugin that has been implemented on Android, but not
on other platforms.
When the action is null, TextButton shows the button
as disabled.
This example is pretty simple, with a single platform check.
But we found that checks can get more complicated,
like when we add support for a second platform--
notice how iOS now supports downloading files--
or when we add a second UI treatment,
this time removing the button in a different area of code.
But you don't want to duplicate code,
so you make a utility function.
Then we take advantage of a new permission in Android 11
for managing external files.
Then Flutter adds support for a new platform, like the web.
This is another change in the system.
As an app developer, it's awesome, so
good, to get another platform for minimal work.
But if you wanted to do a demo of what your existing code
base looks like on a new platform,
we need to audit all the places we branch
and make sure the new platform follows
the paths that are correct.
In this case, the web supports downloading
files, but not the expanded feature enabled on Android.
So what is our solution?
Capabilities and policies-- we have found that separating out
capabilities and policies has helped us branch code
while minimizing the complexity branch code presents.
These layers live above your plugins in your app code
and give you a testable and easy-to-maintain way
to branch UI.
Capabilities can describe what the code can do.
If you ignored your capabilities check,
you would expect your code to fail.
Policy covers what your code should do.
It represents your organization's preference
and can change when your team changes their mind safely.
So how should you structure these concepts
of capability and policy?
That depends on your team, its structure and preferences.
However, I'll give you three options to consider.
You can have a single class, add methods to your plugins,
or have one class per feature area.
A single class can make usage easy
and is the example I'll show in a second.
But it can be unwieldy if you have a lot of code.
Methods on a plugin that expose what it can do
will help if you maintain the plugin.
But if you use plugins maintained by others,
you'll need some way in your app code
to make those determinations.
One class per feature area can work,
but it might be overkill, depending
on the size of your code base.
To back whatever structure you use,
I again offer three suggestions for when
you should evaluate either capabilities or policies,
compile time, runtime, and/or RPC-backed evaluation.
We use all three.
Let's talk about why you might choose each.
Compile time checks are good for platforms where the policy
or capability is unlikely to change
or accidentally changing the value might have
large consequences-- for example,
if a platform does not have some piece of functionality
or a distributor requires you use a specific payment
provider, given the content of your app.
Even if your code could launch a web view to take payments,
the policy says you shouldn't.
Runtime checks are required for determining capabilities
that can change based on the device running
the code, like if there's a touch screen that the user can
use.
RPC-backed policy changes are good for incremental feature
rollout, app deprecation, or for decisions
that might change later, like a landing page for a customer
support link.
Fetching these values could be a new call to your backends.
Or you could take advantage of Firebase Remote Config.
Here are some examples we have in our code base
that I think helps with understanding the concepts.
RequiresBluetoothPermission-- check is true on Android S
and above and on iOS 13 and above because there's
additional UI that explains the need for Bluetooth permissions
and makes sure that they're granted by the user.
HasTouchscreen requires a system feature on Android
and is supported by all of our targeted iOS clients
and supports the web devices based on their max touchpoints.
On the policy side of things, allowPurchaseUrls
is False on iOS to align with their App Store guidelines,
given the content of our app.
PreferredInputType is used when there
are multiple connected input devices
and determines which one should be chosen by default.
This varies a lot per platform.
Let me give you kind of a rundown.
On TVs, we prefer controllers.
Phones and tablets prefer touch.
On the web, we prefer keyboard and mouse,
unless there's a touchscreen and the form factor is small.
All of this can be combined with additional business logic that
says the active input type is the input that's
most recently used.
But preferredInputType helps set the correct default
until the user takes some action.
It's one of those pieces of invisible code
that just makes the experience be what you want it to be.
Here's an example using a single-class version
of the capability we discussed earlier.
We use dependency injection to get the capability service.
This is critical because it means that writing tests,
we can provide a mock and check any branching behavior
without having to run our tests on different platforms.
In this example, similar to the bad example
I showed before, the code base we're working in
has added the ability to download
files for some platforms, but not all.
When not supported, the button appears
disabled, just like before.
And users can't click the Download
if they're on a platform that doesn't
have the functionality yet.
This might be a good experience if you're
trying to quickly show off what your code base might look
like on a new platform, where the goal is
to highlight the functionality that would still
need to be built--
for example, if you wanted to see what a Flutter app
previously targeting mobile might
look like running in a browser.
In this example, we have a policy
for if purchase URLs are allowed.
Note the policy layer doesn't evaluate purchase URLs.
And it still relies on your engineer
to know that this is a concern for external URLs.
In this case, we're assuming the support page will eventually
go to a page that accepts payments
and that isn't OK on some platforms.
Notice here that the UX treatment is very different.
We're changing the content displayed
to a shorter URL that is easier to type into another device--
in addition, changing the behavior of the button from
launchUrl to copy to Clipboard.
This is so powerful.
If your team has dozens of places where you come up
with specific UI treatments that change the behavior,
those places don't need to know about where
the code is running.
Every branch can be unit-tested.
And if you add another platform or if your preferences change,
it's easy to make sure your behavior is
consistent with your past self, without having
to reevaluate all the code and the decisions you made before.
Now, back to CodeFu to talk about some "byte-sized" tips.
JOHN "CODEFU" MCDOLE: "Byte-sized" tips--
make your life easy.
Check pub.dev.
There are many plugins that are already available
and you may not have to write one.
Keep your business logic in Dart.
Share it cross-platform and try to think
of your native implementation as fulfilling a thin contract.
Native exceptions are hard to trace.
Rethrow platform exceptions as wrapped typed exceptions
for better handling in Dart.
And articulate what happens if you call your code where
functionality is missing.
Just don't crash.
Let's quickly recap what we covered today.
We learned the basics of what a federated plugin and we covered
communicating between Dart and platform code
using proto buffs.
We shared some ways to define constant values between plugins
and avoid the pitfalls of magic strings.
We examined why we should avoid the branching and UI code.
And we shared some "byte-sized" tips.
We hope that this helps you build
powerful federated plugins.
Thank you for joining us today.
[MUSIC PLAYING]