본문으로 바로가기
반응형

Photo by unsplash

이전글에서 생성한 xcfFramework을 flutter plugin으로 만드는 과정을 설명합니다. 이렇게 만들면 KMP를 통해서 생성한 library를 flutter에도 plugin 형태로 배포할수 있습니다. 

Flutter Plugin Project 생성

먼저 Flutter Plugin Project를 생성합니다. Android studio에서 flutter plugin이 설정되어 있다면 "File -> New -> New Flutter Project"에서도 UI를 통해서 만들수 있지만, 여기서는 cmd 명령어로 생성해 보겠습니다.

flutter create --template=plugin --platforms=windows,macos kmm_plugin

plugin 템플릿을 사용하고, windows와 macos에서 사용할수 있도록 합니다. (물론 window에서는 생성된 library를 호출하지 않습니다만, 추후 desktop을 지원하기 위하여 추가해 놓습니다.)

project 이름은 kmm_plugin으로 하겠습니다.

생성된 project는 아래와 같습니다.

  • example: 생성된 plugin을 테스트해 볼수 있도록 flutter에서 제공하는 폴더 (main.dart가 존재)
  • lib: import된 library를 wrapping하여 dart함수를 제공하는 부분
  • macos: import된 library를 위치시키고 plugin에 연결하기위해 설정하는 부분

macos 폴더 설정

먼저 이전에 빌드한 framework을 그대로 macos아래로 복사합니다.

생성된 framework중에 debug 용으로 생성된 framework을 이동시키겠습니다.

위 그림과 같이 project 구조로 봤을때 최상위의 macos/share.xcframework에 위치하게 됩니다. (example 안에도 macos 폴더가 있습니다만 거기에 복사하는게 아닙니다.)

위 캡쳐사진에 보이는 macos/kmm_plugin.podspec에 아래와 vendored_frameworks를 같이 추가합니다.

  s.vendored_frameworks = 'shared.xcframework'

이때 'shared.xcframework'은 방금 복사해서 넣은 이름을 넣어야 합니다. 

실제 추가되고 나면 kmm_plugin.podspec 파일은 아래와 같습니다.

Pod::Spec.new do |s|
  s.name             = 'kmm_plugin'
  s.version          = '0.0.1'
  ...
  s.swift_version = '5.0'
  s.vendored_frameworks = 'shared.xcframework'
end

터미널을 열어 "example/macos/"로 이동한 후 하기 명령어를 수행하여 Runner.xcworkspace 파일에 추가한 xcframework을 통합 시킵니다.

pod install

 

Library 호출을 위한 macos 작업

하기 위치의 macos/KmmPlugin.swift 를 열면 아래와 같이 기본설정이 되어 있습니다.

public class KmmPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "kmm_plugin", binaryMessenger: registrar.messenger)
    let instance = KmmPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    case "getPlatformVersion":
      result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString)
    default:
      result(FlutterMethodNotImplemented)
    }
  }
}

이미 methodChannel이 연결되어 있고, 추가한 함수만 새로 정의하면 됩니다.

참고로 KMP에서 kotlin 코드로 생성했던 함수는 아래와 같습니다.

이 함수를 호출할수 있도록 library의 baseName을 import해주고, handle()쪽에 아래와 같이 추가해 줍니다.

import Cocoa
import FlutterMacOS
import Shared //import 추가 kmm 빌드시 사용했던 baseName

public class KmmPlugin: NSObject, FlutterPlugin {
 ...
  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    case "getPlatformVersion":
      result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString)
    // library에 정의한 함수 추가
    case "connectTest":
      let resultValue = MyLibInterface().connectTest()
            result(resultValue)
    default:
      result(FlutterMethodNotImplemented)
    }
  }
}

참고로 baseName은 kmm project에서 아래와 같이 build.gradle.kt에 설정한 이름 입니다.

앞선 빌드쪽 포스팅에서 언급했지만 connectTest()함수는 class의 멤버함수로 객체의 instance가 필요합니다. 따라서 여기서는 MyLibInterface()라는 객체 생성후에 호출하도록 하였으나, 사실 이는 바람직 하지 않습니다. 

실제 호출이 되기는 하나, 함수가 parameter를 받는 형태라면 호출 실패가 날수도 있습니다. 따라서 kmp에서 함수는 top-level function으로 노출되어야 합니다. 

//MyLibInterface.kt

@OptIn(ExperimentalNativeApi::class)
@CName("test")
fun test(): String {
    return "Connection Test Success !!!"
}

만약 위와 같은 함수를 MyLibInterface.kt 파일에서 Top-level 함수로 노출시켜 놓았다면 아래와 같이 호출함수를 만들어야 합니다.

import Cocoa
import FlutterMacOS
import Shared //import 추가 kmm 빌드시 사용했던 baseName

public class KmmPlugin: NSObject, FlutterPlugin {
 ...
  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
      ...
    case "connectTest":
      ...
    case "test":
      let topLevelFuncResult = MyLibInterfaceKt .test()
            result(topLevelFuncResult)
    default:
      result(FlutterMethodNotImplemented)
    }
  }
}

호출시 "파일명Kt.함수명()"으로 호출해야 정상적으로 호출됩니다. -> MyLibInterfaceKt.test()

Library 호출을 위한 Dart wrapping

이번엔 lib 폴더의 파일을 수정하여 library에서 생성한 함수를 호출할수 있도록 native 함수를 dart로 wrapping하겠습니다.

 

kmm_plugin_method_interface.dart 파일을 엽니다. 기본적인 코드는 하기와 같이 작성되어 있습니다.

abstract class KmmPluginPlatform extends PlatformInterface {
  /// Constructs a KmmPluginPlatform.
  KmmPluginPlatform() : super(token: _token);

  static final Object _token = Object();

  static KmmPluginPlatform _instance = MethodChannelKmmPlugin();

...

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

실제 추가해 놓은 함수를 여기에 정의 합니다. 이 interface는 kmm_plugin_method_channel.dart에서 상속받아 구현합니다.

lib에서 생성했던 함수의 정의를 아래와 같이 합니다.

abstract class KmmPluginPlatform extends PlatformInterface {
...

  static set instance(KmmPluginPlatform instance) {
    PlatformInterface.verifyToken(instance, _token);
    _instance = instance;
  }

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

  //호출할 함수 추가
  Future<String> connectTest() {
    throw UnimplementedError('connectTest() has not been implemented.');
  }
}

이제 kmm_plugin_method_channel.dart 파일을 열어 위에서 정의해 놓은 함수를 override 합니다.

/// An implementation of [KmmPluginPlatform] that uses method channels.
class MethodChannelKmmPlugin extends KmmPluginPlatform {
  /// The method channel used to interact with the native platform.
  @visibleForTesting
  final methodChannel = const MethodChannel('kmm_plugin');

  @override
  Future<String?> getPlatformVersion() async {
    final version = await methodChannel.invokeMethod<String>('getPlatformVersion');
    return version;
  }
}

기본적으로 channel을 생성해 놓고 있습니다. 이때 MethodChannel의 'kmm_plugin'은 프로젝트 이름으로 다른 plugin과 중복되지 않도록 하기위해 자동으로 프로젝트 이름으로 설정 됩니다. invokeMethod()를 통해서 호출하며, 이때 인자로 함수명을 적어줍니다.

/// An implementation of [KmmPluginPlatform] that uses method channels.
class MethodChannelKmmPlugin extends KmmPluginPlatform {
  /// The method channel used to interact with the native platform.
  @visibleForTesting
  final methodChannel = const MethodChannel('kmm_plugin');

  @override
  Future<String?> getPlatformVersion() async {
    final version = await methodChannel.invokeMethod<String>('getPlatformVersion');
    return version;
  }
  
  //library 함수를 호출
  @override
  Future<String> connectTest() async {
    final connectMsg = await methodChannel.invokeMethod<String>('connectTest');
    if (connectMsg == null) {
      return 'library connection failed';
    } else {
      return connectMsg;  
    }
  }
}

마지막으로 kmm_plugin.dart를 열고, 새로 추가한 connectTest()함수를 추가합니다.

class KmmPlugin {
  Future<String?> getPlatformVersion() {
    return KmmPluginPlatform.instance.getPlatformVersion();
  }

  //실제 외부에서 호출되는 함수
  Future<String> connectTest() {
    return KmmPluginPlatform.instance.connectTest();
  }
}

이로써 plugin에 library 연결은 끝났습니다.

이제 test를 통해 해당 함수가 잘 호출되는지 확인하면 됩니다.

example을 통한 동작 확인

이제 example package를 이용하여 plugin에 추가한 함수를 호출해 보겠습니다.

example/lib/main.dart 함수를 열면 이미 기본적인 샘플 코드가 들어가 있습니다.

단순히 실행하면 아래와 같은 화면이 뜨면 maing.dart의 코드는 아래와 같습니다.

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String _platformVersion = 'Unknown';
  final _kmmPlugin = KmmPlugin();

  @override
  void initState() {
   ...
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> initPlatformState() async {
     ...
  }

  @override
  Widget build(BuildContext context) {
    ...
  }
}

저 화면에 libraray를 호출하여 받은 string을 하위에 노출시켜 보도록 하겠습니다.

아래와 같이 주석으로 처리된 코드를 넣어 줍니다.

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String _platformVersion = 'Unknown';
  final _kmmPlugin = KmmPlugin();

  String _kmmLibConnectValue = "Not connected!"; //추가

  @override
  void initState() {
    super.initState();
    initPlatformState();
    connectMacOSLibrary(); //추가
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> initPlatformState() async {
     ...
  }

  // 추가
  Future<void> connectMacOSLibrary() async {
    String connectValue;
    try {
      connectValue =
          await _kmmPlugin.connectTest();
    } on PlatformException {
      connectValue = 'Failed to connect mac os';
    }

    if (!mounted) return;

    setState(() {
      _kmmLibConnectValue = connectValue;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          //추가 - 마지막에 호출한 함수의 결과값이 담인 _KmmLibConnectValue를 찍도록 함.
          child: Text('Running on: $_platformVersion\n\n lib connect: $_kmmLibConnectValue'),
        ),
      ),
    );
  }
}

이제 실행하면 아래와 같은 화면이 나타납니다.

추가 사항

만약 library에서 호출하는 함수가 network에 접근해야 한다면 아래와 같은 permission을 추가 해야 합니다.

Network 권한 추가

network 권한은 plugin을 호출하는 외부 앱에 선언되어 있어야 합니다. (즉 plugin 프로젝트에서는 example/macos/Runner 내부의 파일에 권한을 추가해야 합니다.)

  • example/macos/Runner/DebugProfile.entitlements
  • example/macos/Runner/Release.entitlements

파일 내부에 아래 내용을 추가 합니다.

    <key>com.apple.security.network.client</key>
    <true/>

실제 파일에서 추가 위치

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    ...
    <key>com.apple.security.network.client</key>
    <true/>
</dict>
</plist>

 

https 가 아닌 http url에 접속시

info.plist에 아래 권한 추가

    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
    <key>NSLocalNetworkUsageDescription</key>
    <string>로컬 네트워크에 접근하기 위해 필요합니다.</string>

실제 파일에서 추가 위치

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
    <key>NSLocalNetworkUsageDescription</key>
    <string>로컬 네트워크에 접근하기 위해 필요합니다.</string>	
    ...
</dict>
</plist>

References

[1] https://proandroiddev.com/how-to-use-kmp-library-inside-the-flutter-plugin-5f74722c7b3c

반응형