FLUTTER

Flutter 플러그인 만들어보기

지희욱 프로필 이미지

지희욱

2023.07.28

11 min read

Flutter 플러그인 만들어보기

플러그인을 만들어보자


이번에 플러그인을 직접 만들어보며 flutter plugin에 대해 정리해봤습니다.
이 글에서 만들어볼 예제 플러그인은 version을 리턴해주는 아주 간단한 기능 하나를 가지고 있고, 이름은 version입니다.
version 플러그인을 flutter에서 추천하는 Federated Plugin 구조를 사용하여 개발해보도록 하겠습니다.

Federated Plugin Structure란?


Federated plugin structure가 적용된 위 url_launcher 패키지의 경우 아래 세 가지 패키지로 구성되어있습니다.

url_launcher

  • app-facing package 라고 합니다.
  • 플러그인 사용자들이 사용하는 패키지입니다.

url_launcher_platform_interface

  • platform interface package 라고 합니다.
  • platform 패키지의 인터페이스이며, 기능을 명세합니다. 이를 구현한 platform 패키지는 각자 플랫폼이 달라도 동일한 형태로 동일한 기능을 제공합니다.
  • app-facing과 platform이 의존성을 가지고 있고, 서로를 연결해주는 패키지입니다.

url_launcher_web

  • platform package 라고 합니다.
  • 플랫폼 별 구현체 중 web 패키지입니다. platform interface package를 extends 합니다.

Federated plugin structure 구조의 장점은 다음과 같습니다.

  • 플러그인 작성자가 모든 플랫폼에 대해 알고 있지 않아도 됩니다.
  • 플러그인 작성자가 코드를 검토하고 가져올 필요 없이 새 플랫폼에 대한 지원을 추가할 수 있습니다.
  • 플랫폼 패키지들은 각각 유지보수되고 테스트될 수 있습니다.

폴더 구조


version/
├─ version/
├─ version_platform_interface/
├─ version_ios/
├─ version_android/
.gitignore
README.md

package, plugin 생성 명령어는 root 폴더(최상위 version)에서 실행합니다.

Platform Interface Package


생성

flutter create --template=package version_platform_interface

Pubspec.yaml

name: version_platform_interface

#...

dependencies:
  flutter:
    sdk: flutter
  plugin_platform_interface: ^2.1.4

VersionPlatformInterface.dart

import 'package:plugin_platform_interface/plugin_platform_interface.dart';

abstract class VersionPlatform extends PlatformInterface {
  VersionPlatform() : super(token: _token);

  static final Object _token = Object();

  static VersionPlatform? _instance;

  static VersionPlatform get instance => _instance ?? VersionDefault();

  static set instance(VersionPlatform instance) {
    PlatformInterface.verify(instance, _token);
    _instance = instance;
  }

  Future<String?> getPlatformVersion() {
    throw UnimplementedError('platformVersion() has not been implemented.');
  }
}

이 인터페이스는 아래 두 가지 역할을 수행하고 있습니다.

instance set/get

set

  • 실제 플랫폼 별 구현체 인스턴스를 등록하기 위한 instance setter가 있고, setter 안에서는 verify 메서드를 사용하여 해당 인스턴스가 VersionPlatformInterface 를  구현하지 않고 올바르게 상속 하였는지 검증 후 등록합니다.
💡
구현이 아닌, 상속해야하는 이유
만약 해당 클래스가 extend가 아닌 implement를 한다면 이후 VersionPlatformInterface에 새롭게 추가되는 메서드가 있을 때 breaking change가 발생하기 때문입니다.

get

  • 인스턴스가 등록되어있다면 해당 인스턴스를 가져오고, 아니라면 Default 인스턴스를 가져옵니다.

기능 명세

플랫폼 버전을 가져오는 getPlatformVersion 메서드를 명세합니다. 실제 구현은 각 플랫폼 플러그인에서 할테니 안에서는 UnimplementError를 던집니다.

VersionDefault.dart

class VersionDefault extends VersionPlatform {}

등록된 인스턴스가 없을 때 넣어줄 기본 구현체입니다. VersionPlatform 클래스를 extend하고 있고, 아무 일도 하지 않기 때문에 부모의 UnimplementError 를 그대로 던집니다.

배포

app-facing package와 구현체 패키지들이 이 패키지를 참조해야하므로 가장 먼저 배포합니다.

dart pub publish
  • 배포를 하게 되면 되돌릴 수 없고, 삭제할 수도 없으므로 필요없는 파일을 제외하거나 세팅에 대해 다시 한번 확인합니다.
  • LICENSE를 입력하고, CHANGELOG.md를 작성, 버전을 확인합니다.

Platform Package


이 글에서 구현하는 platform package는 각 플랫폼(android, iOS) 별로 MethodChannel을 이용해 해당 플랫폼 별 버전 데이터를 받는 역할을 합니다. MethodChannel을 이용해 주고 받는 데이터 형식은 공식문서에서 확인할 수 있습니다.

Platform Package - Android


생성

flutter create --template=plugin version_android --platform android --org com.example

Pubspec.yaml

name: version_android

#...

flutter:
  plugin:
    implements: version
    platforms:
      android:
				package: com.example.version_android
        dartPluginClass: VersionAndroid
        pluginClass: VersionAndroidPlugin
dependencies:
  flutter:
    sdk: flutter
  version_platform_interface: ^0.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  • package를 명시합니다.
  • implements를 통해 이 구현체가 어떤 패키지를 implement하고 있는지 명시합니다.
  • platforms에서 dartPluginClass 항목에 VersionPlatform 을 구현할 클래스 VersionAndroid를 넣습니다. 이를 통해 VersionAndroidregisterWith 가 실행됩니다.
  • dependencies에 version_platform_interface 를 추가합니다.

VersionAndroid.dart

import 'package:flutter/services.dart';
import 'package:version_platform_interface/version_platform.dart';

const MethodChannel _channel = MethodChannel('example.com/version_android');

class VersionAndroid extends VersionPlatform {

  static void registerWith() {
    VersionPlatform.instance = VersionAndroid();
  }

  @override
  Future<String?> getPlatformVersion() {
    return _channel
        .invokeMethod<String>('getPlatformVersion');
  }
}

위에서는 아래 두 가지 역할을 수행합니다.

instance 등록

registerWith 메서드가 실행되면 PlatformInterface를 extend하고 있는 VersionAndroid 인스턴스를 등록합니다.

기능 구현

Platform Interface Package 에서 명세해놓은 getPlatformVersion 메서드를 override하여 실제로 구현합니다.

VersionAndroidPlugin.kt

package com.example.version_android

import androidx.annotation.NonNull

import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result

class VersionAndroidPlugin: FlutterPlugin, MethodCallHandler {
  private lateinit var channel : MethodChannel

  override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    channel = MethodChannel(flutterPluginBinding.binaryMessenger, "example.com/version_android")
    channel.setMethodCallHandler(this)
  }

  override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
    if (call.method == "getPlatformVersion") {
      result.success("Android ${android.os.Build.VERSION.RELEASE}")
    } else {
      result.notImplemented()
    }
  }

  override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
    channel.setMethodCallHandler(null)
  }
}

VersionAndroidPlugin은 아래 역할들을 수행합니다.

플러그인 등록

FlutterPlugin interface를 implement합니다. 그러면 이 플러그인을 사용하는 프로젝트 빌드시 자동 생성되는 GeneratedPluginRegistrant.java 라는 파일 안에서 다음과 같이 플러그인이 등록됩니다.

//...
flutterEngine.getPlugins().add(new com.example.version_android.VersionAndroidPlugin());
//...

또한, 플러그인이 플러터 엔진에 attach(onAttachedToEngine), detach(onDetachedFromEngine) 되었을 때의 Lifecycle을 통해 plugin에 필요한 작업을 할 수 있습니다. (리소스 등록, 해제)

MethodChannel 생성, Handler 등록

flutter와 데이터를 주고받을 수 있는 channel을 생성하고, handler를 등록합니다. MethodCallHandler 를 implement하고 onMethodCall 을 override하고 있으므로 channel.setMethodCallHandler에 this를 전달하면 channel을 통해 신호가 왔을 때 onMethodCall 함수가 실행됩니다.

기능 구현

result에 현재 시스템 버전을 담아 return합니다. (플러그인 생성 시 기본적으로 들어가있는 코드)

Activity

만약 플러그인이 현재 플러터의 Activity에 접근해야하거나, Android activity Lifecycle(e.g, onCreate, onStart, onDestroy)에 따라 필요한 작업이 있을 경우, 다음과 같이 ActivityAware 를 implement 해줍니다.

class VersionAndroidPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
	private lateinit var binaryMessenger: BinaryMessenger
	private lateinit var channel: MethodChannel
	private lateinit var activity: Activity

  override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    binaryMessenger = flutterPluginBinding.binaryMessenger
  }
  override fun onAttachedToActivity(binding: ActivityPluginBinding) {
    activity = binding.activity
    channel = MethodChannel(binaryMessenger, "example.com/version_android")
    channel.setMethodCallHandler(this)
  }
  override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
    onAttachedToActivity(binding)
  }
  override fun onDetachedFromActivityForConfigChanges() {}

  override fun onDetachedFromActivity() {}

  override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result)
{
    if (call.method == "getPlatformVersion") {
      result.success("Android ${android.os.Build.VERSION.RELEASE}")
    } else {
      result.notImplemented()
    }
	}

override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
    channel.setMethodCallHandler(null)
  }

}

위와 같이 onAttachedToActivity를 통해 activity를 저장하고, activity를 이용한 여러 작업을 할 수 있습니다. (e.g, runOnUiThread 를 통해 UiThread에 작업을 실행)

💡
MethodChannel을 등록하는 작업을 onAttachedToActivity 에서 진행하도록 변경했는데, onAttachedToEngine 에서 channel을 등록했을 때 invokeMethod 를 통해 flutter 쪽으로 신호가 가지 않는 예외사항을 확인했습니다. 추측하기로는, 아직 activity가 없는 onAttachedToEngine 에서 channel을 등록한 것이 문제가 되지 않았나 싶습니다.
💡
만약 reflection과 같이 참조하지 않는 class를 만들었다면, 빌드시 android에서 minification을 위해 제거할 수 있으니 Keep annotation 등의 추가 작업을 해주어야합니다.

배포

dart pub publish
  • 배포를 하게 되면 되돌릴 수 없고, 삭제할 수도 없으므로 필요없는 파일을 제외하거나 세팅에 대해 다시 한번 확인합니다.
  • LICENSE를 입력하고, CHANGELOG.md를 작성, 버전을 확인합니다.

Platform Package - ios


생성

flutter create --template=plugin version_ios --platform ios --org com.example

Pubspec.yaml

name: version_ios

#...

flutter:
  plugin:
    implements: version
    platforms:
      ios:
        dartPluginClass: VersionIOS
        pluginClass: VersionIosPlugin

dependencies:
  flutter:
    sdk: flutter
  version_platform_interface: ^0.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  • implements를 통해 이 플러그인이 어떤 플러그인을 implement하고 있는지 명시합니다.
  • platforms에서 dartPluginClass 항목에 VersionPlatform 을 구현할 클래스 VersionIOS를 넣습니다. 이를 통해 VersionIOSregisterWith 가 실행됩니다.
  • dependencies에 version_platform_interface 를 추가합니다.

VersionIOS.dart

import 'package:flutter/services.dart';
import 'package:version_platform_interface/version_platform.dart';

const MethodChannel _channel = MethodChannel('example.com/version_ios');

class VersionIOS extends VersionPlatform {

  static void registerWith() {
    VersionPlatform.instance = VersionIOS();
  }

  @override
  Future<String?> getPlatformVersion() {
    return _channel
        .invokeMethod<String>('getPlatformVersion');
  }
}

위에서는 아래 두 가지 역할을 수행합니다.

instance 등록

registerWith 메서드가 실행되면 PlatformInterface를 extend하고 있는 VersionIOS 인스턴스를 등록합니다.

기능 구현

Platform Interface Package 에서 명세해놓은 getPlatformVersion 메서드를 override하여 실제로 구현합니다.

SwiftVersionIosPlugin.swift

import Flutter
import UIKit

public class SwiftVersionIosPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "example.com/version_ios", binaryMessenger: registrar.messenger())
    let instance = SwiftVersionIosPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    result("iOS " + UIDevice.current.systemVersion)
  }
}

ios 폴더 안의 SwiftVersionIosPlugin.swift 파일을 열어보면 위와 같이 두 가지 역할을 하는 코드가 있습니다.

instance, MethodChannel 등록

SwiftVersionIosPluginmethodChannel을 생성해서 FlutterPluginRegistrar 에 등록합니다. methodChannel의 이름은 위 VersionIOS 클래스에 선언한 이름과 동일해야합니다.

기능 구현

result에 현재 시스템 버전을 담아 return합니다. (플러그인 생성 시 기본적으로 들어가있는 코드)

배포

dart pub publish
  • 배포를 하게 되면 되돌릴 수 없고, 삭제할 수도 없으므로 필요없는 파일을 제외하거나 세팅에 대해 다시 한번 확인합니다.
  • LICENSE를 입력하고, CHANGELOG.md를 작성, 버전을 확인합니다.

App-Facing Package


마지막으로 유저들이 실제로 사용하게 될 facing package입니다.

생성

flutter create --template=plugin version

Pubspec.yaml

name: version

#...

flutter:
  plugin:
    platforms:
      android:
        default_package: version_android
			ios:
        default_package: version_ios

dependencies:
  flutter:
    sdk: flutter
  version_platform_interface: ^0.0.1
	version_android: ^0.0.1
  version_ios: ^0.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: 2.0.1
  plugin_platform_interface: ^2.1.4
  • platforms에 구현하려고 하는 플랫폼의 plugin을 명시합니다.
  • dependencies에 platform_interface와 각 플랫폼 plugin을 넣습니다.
  • dev_dependencies에 plugin_platform_interface를 넣습니다.

PlatformInterface 사용

import 'package:version_platform_interface/version_platform.dart';

//...

Future<String?> getPlatformVersion() {
    return VersionPlatform.instance.getPlatformVersion();
}

PlatformInterface의 메서드를 포팅합니다. 실제 돌아가고 있는 instance가 어떤 것인지 정확히 알지 못해도 각 플랫폼에서 구현한 기능을 제공받습니다.

App-Facing Package Example


이제 example project를 실행해보면(없다면 flutter create . 를 이용해서 예제 프로젝트를 생성) 위와 같이 version을 확인할 수 있습니다. (version을 출력하는 example code가 자동으로 들어가 있습니다.)

배포

dart pub publish
  • 배포를 하게 되면 되돌릴 수 없고, 삭제할 수도 없으므로 필요없는 파일을 제외하거나 세팅에 대해 다시 한번 확인합니다.
  • LICENSE를 입력하고, CHANGELOG.md를 작성, 버전을 확인합니다.

맺으며

플러그인을 만들어서 배포하는 것은 생각보다 쉽습니다.
업무 상 필요한 기능이 아직 pub.dev 에 없다면, 만들어서 배포해보는 건 어떨까요?

참고 자료



커리어의 성장을 돕는 코멘토에서 언제나 함께 성장할 개발자를 기다리고 있습니다. 채용 페이지에서 코멘토가 어떤 회사인지, 어떤 사람을 찾는지 더 자세히 확인해보세요. 😊