Skip to content

Latest commit

 

History

History
529 lines (426 loc) · 22.9 KB

11.md

File metadata and controls

529 lines (426 loc) · 22.9 KB

Chapter 11: How to use Platform-specific APIs with Platform Channels and testing Flutter Apps

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.

Platform-specific APIs

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).

Platform Channels

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.

Architectural overview: platform channels

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.

Type support

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:
Supported types

Sending a Method invoking from Flutter

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}'."; 
}

Running native Android code

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.

Running native iOS code

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:

[ios/Runner/AppDelegate.swift]
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)  
    }  
}

Add Flutter to an existing mobile app

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.

Introduction to test Flutter applications

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

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.

Writing the first unit test

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

Testing Platform Channels

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.

[./test/data/native/native_datasource_test.dart]
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.

Testing BloCs

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.

[./test/ui/home/bloc/home_bloc_test.dart]
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.

Widget tests

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.

Further reading, materials