Handling Different Platform APIs for Similar Functionality in Flutter

Flutter is a powerful cross-platform framework that allows developers to write code once and deploy it on multiple platforms, including Android, iOS, web, and desktop. However, each platform has its own set of APIs and functionalities, leading to situations where you need to handle different platform APIs for similar functionality. This post explores strategies and best practices for effectively handling different platform APIs in Flutter.

The Challenge of Platform-Specific APIs

When building cross-platform applications, it’s common to encounter scenarios where certain functionalities are implemented differently across platforms. For example:

  • File Access: Accessing files on Android requires different APIs than on iOS.
  • Notifications: Sending push notifications involves platform-specific services like Firebase Cloud Messaging (FCM) for Android and Apple Push Notification service (APNs) for iOS.
  • Bluetooth: Implementing Bluetooth functionality necessitates using different platform APIs on Android and iOS.

To address these challenges, Flutter provides several mechanisms for interacting with platform-specific code.

Methods for Handling Platform APIs in Flutter

1. Platform Channels

Platform channels are Flutter’s primary mechanism for communicating between Dart code and platform-specific code (written in Kotlin/Java for Android and Swift/Objective-C for iOS). They allow you to invoke native APIs and receive results back in your Flutter application.

How Platform Channels Work
  • Dart Code: Sends a method call over a platform channel.
  • Platform Code: Receives the method call, executes the corresponding native code, and sends the result back to Flutter.
Example: Accessing Battery Level

Here’s an example of using platform channels to access the battery level on Android and iOS.

Step 1: Define a Platform Channel in Dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class BatteryLevel extends StatefulWidget {
  @override
  _BatteryLevelState createState() => _BatteryLevelState();
}

class _BatteryLevelState extends State {
  static const platform = const MethodChannel('com.example.app/battery');

  String _batteryLevel = 'Unknown battery level.';

  Future _getBatteryLevel() async {
    String batteryLevel;
    try {
      final int result = await platform.invokeMethod('getBatteryLevel');
      batteryLevel = 'Battery level at $result % .';
    } on PlatformException catch (e) {
      batteryLevel = "Failed to get battery level: '${e.message}'.";
    }

    setState(() {
      _batteryLevel = batteryLevel;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Battery Level'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_batteryLevel),
            ElevatedButton(
              onPressed: _getBatteryLevel,
              child: Text('Get Battery Level'),
            ),
          ],
        ),
      ),
    );
  }
}
Step 2: Implement Platform-Specific Code

Android (Kotlin):

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.example.app/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "getBatteryLevel") {
                val batteryLevel = getBatteryLevel()

                if (batteryLevel != -1) {
                    result.success(batteryLevel)
                } else {
                    result.error("UNAVAILABLE", "Battery level not available.", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }

    private fun getBatteryLevel(): Int {
        val batteryLevel: Int = if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
            val batteryManager: BatteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
            batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
        } else {
            val intent = ContextWrapper(applicationContext).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
            intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
        }
        return batteryLevel
    }
}

iOS (Swift):

import Flutter
import UIKit

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let batteryChannel = FlutterMethodChannel(name: "com.example.app/battery",
                                              binaryMessenger: controller.binaryMessenger)
    batteryChannel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      guard call.method == "getBatteryLevel" else {
        result(FlutterMethodNotImplemented)
        return
      }
      self.receiveBatteryLevel(result: result)
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func receiveBatteryLevel(result: @escaping FlutterResult) {
    let device = UIDevice.current
    device.isBatteryMonitoringEnabled = true
    if device.batteryState == UIDevice.BatteryState.unknown {
      result(FlutterError(code: "UNAVAILABLE",
                          message: "Battery info unavailable",
                          details: nil))
    } else {
      result(Int(device.batteryLevel * 100))
    }
  }
}

This example demonstrates how to use platform channels to retrieve battery information, showcasing the platform-specific implementations.

2. Conditional Compilation

Flutter supports conditional compilation using Dart’s dart:io library to check the operating system at compile time. This approach is useful for platform-specific logic that doesn’t require invoking native APIs directly but rather executing different Dart code based on the platform.

How Conditional Compilation Works
  • Dart Code: Uses dart:io to determine the current platform.
  • Execution: Executes different code branches based on the identified platform.
Example: Displaying Platform-Specific Text
import 'dart:io' show Platform;
import 'package:flutter/material.dart';

class PlatformSpecificText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    String platformText;
    if (Platform.isAndroid) {
      platformText = 'Running on Android';
    } else if (Platform.isIOS) {
      platformText = 'Running on iOS';
    } else {
      platformText = 'Running on an unknown platform';
    }

    return Scaffold(
      appBar: AppBar(
        title: Text('Platform Specific Text'),
      ),
      body: Center(
        child: Text(platformText),
      ),
    );
  }
}

This example illustrates how to display different text based on the platform the app is running on.

3. Package Plugins

Flutter’s package ecosystem includes plugins that abstract platform-specific code, providing a Dart API for common functionalities. Using plugins simplifies your code and reduces the need to write platform-specific code directly.

Benefits of Using Plugins
  • Abstraction: Plugins handle platform-specific details, providing a consistent Dart API.
  • Ease of Use: Simplifies common tasks like accessing device sensors or managing local storage.
  • Community Support: Benefit from community-maintained and tested solutions.
Example: Using the device_info_plus Plugin

The device_info_plus plugin provides a Dart API to access device information.

Step 1: Add Dependency

Add device_info_plus to your pubspec.yaml file:

dependencies:
  device_info_plus: ^9.0.0
Step 2: Use the Plugin in Your Dart Code
import 'package:flutter/material.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';

class DeviceInfo extends StatefulWidget {
  @override
  _DeviceInfoState createState() => _DeviceInfoState();
}

class _DeviceInfoState extends State {
  String _deviceInfo = 'Unknown device info.';

  @override
  void initState() {
    super.initState();
    _getDeviceInfo();
  }

  Future _getDeviceInfo() async {
    DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
    String info;

    if (Platform.isAndroid) {
      AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
      info = 'Running on ${androidInfo.model}';
    } else if (Platform.isIOS) {
      IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
      info = 'Running on ${iosInfo.utsname.machine}';
    } else {
      info = 'Could not get device info.';
    }

    setState(() {
      _deviceInfo = info;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Device Info'),
      ),
      body: Center(
        child: Text(_deviceInfo),
      ),
    );
  }
}

This example demonstrates how to use the device_info_plus plugin to retrieve device-specific information and display it in the app.

4. Creating Abstract Classes/Interfaces

For complex functionalities that require significant platform-specific code, creating abstract classes or interfaces can help maintain a clean and modular codebase. This approach involves defining an abstract class in Dart and providing platform-specific implementations in separate files.

How Abstract Classes Work
  • Abstract Class: Defines a common interface for platform-specific implementations.
  • Platform-Specific Implementations: Implement the abstract class in separate files for Android and iOS.
  • Dependency Injection: Use dependency injection to provide the appropriate implementation based on the platform.
Example: Implementing a Custom Analytics Service

Here’s how to create a custom analytics service with platform-specific implementations.

Step 1: Define the Abstract Class
abstract class AnalyticsService {
  Future logEvent(String eventName, Map parameters);
}
Step 2: Implement Platform-Specific Classes

Android (Kotlin):

// AndroidAnalyticsService.kt
import android.content.Context
import android.os.Bundle
import com.google.firebase.analytics.FirebaseAnalytics
import kotlinx.coroutines.CompletableDeferred

class AndroidAnalyticsService(private val context: Context) : AnalyticsService {

    private val firebaseAnalytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(context) }

    override suspend fun logEvent(eventName: String, parameters: Map) {
        val bundle = Bundle()
        for ((key, value) in parameters) {
            when (value) {
                is String -> bundle.putString(key, value)
                is Int -> bundle.putInt(key, value)
                is Double -> bundle.putDouble(key, value)
                is Long -> bundle.putLong(key, value)
                is Float -> bundle.putFloat(key, value)
                else -> throw IllegalArgumentException("Unsupported type: ${value.javaClass.name}")
            }
        }
        firebaseAnalytics.logEvent(eventName, bundle)
    }
}

// Provide dependencies in MainActivity.kt
fun provideAnalyticsService(context: Context): AnalyticsService {
        return AndroidAnalyticsService(context)
}

iOS (Swift):

// SwiftAnalyticsService.swift
import Foundation
import Firebase

class SwiftAnalyticsService: AnalyticsService {

    func logEvent(eventName: String, parameters: [String: Any]) async {
        FirebaseAnalytics.logEvent(eventName, parameters: parameters)
    }
}

// Provide Dependencies in AppDelegate.swift
func provideAnalyticsService() -> AnalyticsService {
       return SwiftAnalyticsService()
}
Step 3: Implement Analytics Service in Dart
//analytics_service.dart
abstract class AnalyticsService {
    Future logEvent(String eventName, Map parameters);
}

class MockAnalyticsService extends AnalyticsService {
  @override
  Future logEvent(String eventName, Map parameters) async {
    print('Logging event: $eventName with parameters: $parameters');
    // No actual analytics logging
  }
}

AnalyticsService getAnalyticsService() => throw UnsupportedError(
    'Cannot create an analytics service without platform support');

This setup provides a clear and modular way to handle analytics logging with platform-specific implementations.

Best Practices for Handling Platform APIs

  • Minimize Platform-Specific Code: Aim to minimize the amount of platform-specific code by using Flutter’s built-in widgets and functionalities whenever possible.
  • Abstract Platform-Specific Logic: Encapsulate platform-specific code behind abstract classes or interfaces to keep your Dart code clean and maintainable.
  • Use Plugins When Available: Leverage existing Flutter plugins for common functionalities to avoid reinventing the wheel.
  • Thorough Testing: Test your application thoroughly on all target platforms to ensure that platform-specific implementations work as expected.
  • Documentation: Document your platform-specific code and the reasons for using it to help other developers understand and maintain your code.

Conclusion

Handling different platform APIs in Flutter requires a strategic approach. By using platform channels, conditional compilation, package plugins, and abstract classes, you can create cross-platform applications that leverage the unique capabilities of each platform while maintaining a clean and maintainable codebase. Understanding and implementing these strategies effectively will help you build robust and versatile Flutter applications.