In the previous chapters, we've learned how we could use Flutter APIs. We also got tons of plugins that help us to provide native features.
What does a native feature mean exactly? The native features are APIs that we can use in a platform-specific way. The following list contains some of the key platform-specific features:
- using the camera,
- using the microphone,
- accessing location data from GPS and displaying it with Google Maps,
- accessing the device's secure storage,
- accessing data from the motion sensors,
- accessing the humidity sensor,
- accessing Bluetooth features,
- accessing NFC features,
- etc.
The time may come when we would need to use at least one of the above and there may not be a plugin to support our needs.
Fortunately, using the Platform channels
, we can easily run platform native code (code written in Objective C
, Swift
, Java
, or Kotlin
).
Flutter gives us platform channels as a bridge between the client (UI) and host (platform), and we can pass events through them.
The following diagram gives an overview of the Platform Channels architecture.
As we can see in the diagram, we can send events using MethodChannel
from Flutter to native and vice versa.
Note that sending events is an asynchronous process in Flutter's Dart side, but despite this, whenever we invoke a channel method, we must invoke that method on the platform's UI (main) thread.
The standard platform channels need a name and a codec for serializing / deserializing messages to binary form and back. The following table shows Dart platform channel types and codecs:
Firstly, we have to create our Platform Channel by passing it a unique ID, for example, a URL:
import 'package:flutter/services.dart';
const platformChannel = const MethodChannel('hu.bme.aut.flutter/data');
After that we can send a request with calling invokeMethod()
method with passing the name of method of the native side. Unfortunately, things can go wrong so we should add try-catch block because if the communication with the platform fails for any reason, we want to be prepares to handle that error like this:
try {
final int result = await platformChannel.invokeMethod('getPlatformSpecificData');
_platformSpecificData = "$result in Celsius";
} on PlatformException catch (error) {
_platformSpecificData = "Failed to get platform specific data: '${error.message}'.";
}
Start by finding the Android host portion of our Flutter application. After that open the MainActivity.kt
located in java or our kotlin folder.
Inside the MainActivity
we have to create and configure our FlutterEngine
. For that we can override configureFlutterEngine()
method of FlutterActivity
class like this:
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val CHANNEL = "hu.bme.aut.flutter/data"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
// Note: this method is invoked on the main thread.
// TODO
}
}
}
Don't forget to add the necessary imports by manually this time.
Inside the above-mentioned configureFlutterEngine()
method, we can create our MethodChannel
with the same name that we use on the Flutter side. Next, we register a method call handler on our channel. The MethodCallHandler
is responsible for handling several specified method calls received from Flutter.
Previously, we specified a method with the getPlatformSpecificData
string so in the body of setMethodCallHandler()
's lambda we handle it like the following code snippet:
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
// Note: this method is invoked on the main thread.
if (call.method == "getPlatformSpecificData") {
val currentTemp = getPlatformSpecificData()
if (currentTemp != null) {
result.success(currentTemp)
} else {
result.error("UNAVAILABLE", "Temperature level not available.", null)
}
} else {
result.notImplemented()
}
}
Handler implementations must submit a result
for all incoming MethodCall
, by making a single call on the given Result
callback. To achieve this, we can handle our results with the success()
and error()
functions. Failure to do so will result in lengthy Flutter result handlers. The result may be submitted asynchronously. Calls to unknown or unimplemented methods can be handled using .notImplemented()
call.
Note that If no handler has been registered, any incoming method call on this channel will be handled automatically by sending a
null
reply.
In the success case our getPlatformSpecificData()
function provides us a valid requested currentTemp
value. Under the hood, we have to implement the SensorEventListener
interface for receiving new sensor data from the native android.hardware.SensorManager
class. In the flutter_platform_channels_basic example, you can find a short solution for TYPE_AMBIENT_TEMPERATURE
measurement under the sensor-specific code
region
comments. These are the native lines of code that we don’t need to implement if we have an available flutter package.
Briefly, to achieve the result on the iOS side, we have to edit the AppDelegate.swift
file in ios/Runner
.
The
AppDelegate
is the core singleton that is part of the app initialization process.
First, we need to override the application:didFinishLaunchingWithOptions:
function and create a FlutterMethodChannel
using its proper name hu.bme.aut.flutter/data
like this:
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(name: "hu.bme.aut.flutter/data",
binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler({
[weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in
// Note: this method is invoked on the UI thread.
guard call.method == "getPlatformSpecificData" else {
result(FlutterMethodNotImplemented)
return
}
self?.getPlatformSpecificData(result: result)
})
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func getPlatformSpecificData(result: FlutterResult) {
result(30)
}
}
Flutter can be integrated into our existing native Android or iOS application, as a library or a module. For more details on this topic, you should check the official Integrate a Flutter module into your Android project documentation.
Before diving into how we test our above-mentioned Platform channels and other projects, we'll look at the base test categories of testing with their official short terms:
- A unit test tests a single function, method, or class.
- A widget test (in other UI frameworks referred to as component test) tests a single widget.
- An integration test tests a complete app or a large part of an ap
Why should we test anything? Testing is an important requirement to deliver applications with the best quality. A good test code coverage can be a measure of quality for production software.
Unfortunately, in this chapter, we don’t have enough time and opportunities to convince you to write tests, follow the TDD, but we'll take look at the basic tools.
Unit tests are responsible for testing separated business logic. The isolating is very important, and if we have some external dependencies of one unit we have to mock out them. To mock dependencies, we can use the recommended Mockito package.
A unit test often falls into three sections:
- In Arrange, we set up everything that is going to be used by the test.
- In the Act, we will call a specific function what we want to test
- In the Assert, we will use some type of
expect()
to make test assertions.
Firstly, we have to add the test package under the dev_dependencies:
in the pubspec.yaml
file:
dev_dependencies:
test: ^1.17.3
For example, we have a complex utils like the following quickMatch()
in a complex_utils.dart
file in the lib
folder:
class ComplexUtils {
static int quickMath() {
return 2 + 2 - 1;
}
}
To test its behavior, we can create a complex_utils_test.dart
file inside the test
folder. The folder structure should look like this:
./
lib/
complex_utils.dart
test/
complex_utils_test.dart
We should use the "_test" convention for ensuring readability and supporting the test runner when it searches tests.
Inside the complex_utils_test.dart
file, by using the top-level test()
function, we can specify our unit test. Moreover, we can check the result of quickMatch()
function by using the top-level expect()
function.
// Import the test package and ComplexUtils class
import 'package:test/test.dart';
import 'package:flutter_app/complex_utils.dart';
void main() {
test("Two plus two is four, minus one that's three", () {
// Arrange
const int expectedResult = 3;
// Act
final result = ComplexUtils.quickMath();
// Assert
expect(result, expectedResult);
});
}
After that, we can run our test with the Flutter plugins of IntelliJ by clicking the green run icons or select our test in the Run/Debug Configurations or we can use the terminal to run it by executing the following command from the root of the project:
flutter test test/complex_utils_test.dart
We have several options regarding a unit test, so you should check them with the following command:
flutter test --help
To test our added Platform Channel, we can find unit tests in the native_datasource_test.dart file of the flutter_platform_channels project. As we see, our Platform Channel can act as a native data source if we build our app using architectural layers.
import 'package:flutter/services.dart';
import 'package:flutter_platform_channels/data/native/channel.dart';
import 'package:flutter_platform_channels/data/native/native_datasource.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import '../../di/di_test_utils.dart';
void main() {
NativeDataSource nativeDataSource;
MethodChannel methodChannel;
setUp(() {
methodChannel = MethodChannelMock();
nativeDataSource = NativeDataSourceImpl(methodChannel: methodChannel);
});
//...
}
In the code snippet above, we import mocked dependencies from our di_test_utils.dart file, where we mock our MethodChannel like this:
import 'package:flutter/services.dart';
import 'package:mockito/mockito.dart';
class MethodChannelMock extends Mock implements MethodChannel {}
Back to the native_datasource_test.dart
, we use the setUp()
function to initialize those objects what we want to share between tests. The setUp()
callback will run before every test suite. There’s another useful function the tearDown()
, which will run after every test, even if a test fails. We use it for testing BloCs.
We can wrap tests using the group()
function, which gives us the possibility to group semantically different unit tests. For this reason, inside the getTemperature()
group we create a group of tests for happy scenarios and one group for error scenarios.
In the following happy scenarios tests, we will use the when()
function provided by Mockito to return always with the same stubbed value as the response when our nativeDataSource
will use via its getTemperature()
function.
//...
group('getTemperature()', () {
group('happy scenarios', () {
test(
'Get the temperature from the method channel successfully with a positive value',
() async {
// Arrange
const int expectedResult = 10;
when(methodChannel.invokeMethod(Channel.getTemp))
.thenAnswer((_) async => expectedResult);
// Act
final result = await nativeDataSource.getTemperature();
// Assert
expect(result, expectedResult);
});
test(
'Get the temperature from the method channel successfully with a negative value',
() async {
// Arrange
const int expectedResult = -10;
when(methodChannel.invokeMethod(Channel.getTemp))
.thenAnswer((_) async => expectedResult);
// Act
final result = await nativeDataSource.getTemperature();
// Assert
expect(result, expectedResult);
});
});
//...
The official name of this mechanism is stubbing. Mockito provides us when
, thenReturn
, thenAnswer
, and thenThrow
APIs to override different method invocations. We use the thenAnswer
to return Future
, and we can write asynchronous tests if we use the async
/ await
properly.
//...
group('error scenarios', () {
test(
'return data error when native fails to return temperature and it returns with null',
() async {
// Arrange
const int response = null;
const String expectedError = 'temp is missing';
when(methodChannel.invokeMethod(Channel.getTemp))
.thenAnswer((_) async => response);
// Act
// Assert
expect(
() => nativeDataSource.getTemperature(),
throwsA(
predicate(
(e) => e is PlatformException && e.message == expectedError),
),
);
});
test(
'return data error when native fails to return temperature and it throws PlatformException',
() async {
// Arrange
const String expectedError = 'temp is missing';
when(methodChannel.invokeMethod(Channel.getTemp))
.thenThrow(PlatformException(message: expectedError, code: ""));
// Act
// Assert
expect(
() => nativeDataSource.getTemperature(),
throwsA(
predicate(
(e) => e is PlatformException && e.message == expectedError),
),
);
});
test(
'return data error when native fails to return temperature and it throws a default exception',
() async {
// Arrange
const String expectedError = 'temp is missing';
when(methodChannel.invokeMethod(Channel.getTemp))
.thenThrow(MissingPluginException());
// Act
// Assert
expect(
() => nativeDataSource.getTemperature(),
throwsA(
predicate(
(e) => e is PlatformException && e.message == expectedError),
),
);
});
});
//...
In the tests of error scenarios above, we use thenThrow()
function for stubbing our methodChannel invoking to throw different exceptions.
In this example, we've learned how to use Mockito to test each condition of our Platform Channel.
Let's see our HomeBloc
in the home_bloc.dart file of the flutter_platform_channels project:
class HomeBloc extends Bloc<HomeEvent, HomeState> {
HomeBloc({
@required this.repository,
}) : super(HomeStateLoading());
final Repository repository;
@override
Stream<HomeState> mapEventToState(HomeEvent event) async* {
if (event is HomeEventGetTemperature) {
yield HomeStateLoading();
yield await _mapLoadTemperatureToState();
}
}
Future<HomeState> _mapLoadTemperatureToState() async {
try {
final int result = await repository.getTemperature();
return HomeStateLoaded(temperature: result);
} catch (e) {
return HomeStateError();
}
}
}
To test HomeBloc
, we define groups for happy and error cases, and using setUp()
we create our bloc
for all tests and with tearDown()
we close it when all tests are completed.
void main() {
HomeBloc bloc;
Repository mockRepository;
setUp(() {
mockRepository = RepositoryMock();
bloc = HomeBloc(repository: mockRepository);
});
tearDown(() {
bloc.close();
});
group("Happy Cases", () {
test('when screen started', () {
// Assert
expect(
bloc.state,
equals(
HomeStateLoading(),
),
);
});
test('when temperature fetching starts and it loads with success', () {
// Arrange
final int expectedResult = 10;
when(mockRepository.getTemperature())
.thenAnswer((_) async => expectedResult);
// Act
bloc.add((HomeEventGetTemperature()));
// Assert
expectLater(
bloc,
emitsInOrder(
[
HomeStateLoading(),
HomeStateLoaded(temperature: expectedResult),
],
),
);
});
});
group("Error Cases", () {
test("when temperature fetching starts but it fails", () {
// Arrange
when(mockRepository.getTemperature())
.thenThrow(MissingPluginException('message'));
// Act
bloc.add((HomeEventGetTemperature()));
// Assert
expectLater(
bloc,
emitsInOrder(
[
HomeStateLoading(),
HomeStateError(),
],
),
);
});
});
}
Thanks to the description of the tests, it is quite clear which state handling, functionalities of the current bloc can be easily tested.
In the section of Unit testing, we learned how to test Dart classes using the test package.
To test widgets, we need to add the flutter_test library under the dev_dependencies:
in the pubspec.yaml
file:
dev_dependencies:
flutter_test:
sdk: flutter
In our Flutter project test folder we create a simple widget_test.dart
with the following code:
void main() {
testWidgets('MyHomePage has a title', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(MyApp());
// Verifications.
expect(find.text("Flutter Platform Channels demo - updated"), findsOneWidget);
expect(find.text('0'), findsNothing);
});
}
As we see, the main difference from the Unit test that we need to use the WidgetTester
tool, which allows building and interacting with widgets in our test environment. To create WidgetTester
, we have to use testWidgets()
function instead of normal test()
function.
Inside our first widget test, we use the pumpWidget()
method provided by the WidgetTester
to build and render our MyApp
widget. The pumpWidget
method creates a MyApp
instance that displays a "Flutter Platform Channels demo - updated" text as the title of its AppBar
. To search through the widget tree for that title, we can use the Finder class.
Ensure that our title appears on the screen exactly one time. For this purpose, we can use the findsOneWidget
Matcher.
There are more matchers and finders for common cases and also it is possible to test different user interactions like:
For more information on how to write more tests, especially integration tests, which we can’t cover in this chapter, see the following materials and further reading.
-
Flutter Platform Channels by Mikkel Ravn
-
Intro to Platform Channels: Building an Image Picker in Flutter by Andrea Bizzotto
-
Your Own Image Picker With Flutter Channels by JB Lorenzo
-
Flutter: Golden tests — compare Widgets with Snapshots by Katarina Sheremet