flutter

오마이컴퍼니 플러터앱 개발기록

Aug 23, 2021

hmm

이미 만들어진 프로젝트의 소스코드를 잘 빌드하고 테스트하고 배포하면 되는 일이었다.

image

예상된 문제이고 대부분의 경우 구글링을 통해 해결이 가능하다. 위의 오류 또한 그렇다. push notification을 끄거나 bundle id를 맞게 하거나 개발팀의 프로파일을 잘 수정하면 되는 게 아닐까?

처음부터 다시 시작하기로 했다..

iOS 개발/배포시 인증서에 관하여

애플의 경우 애플에게 인증된 디바이스에서만 테스트가 가능하도록 하고 있다. 그래서 아이폰의 UUID를 등록해서 프로파일에 추가한 다음에야 테스트가 가능하다.

다른 블로그를 참고해서 따라해 보았다.

Xcode → Preferences → Account 탭에 계정을 잘 등록하고 어떻게 잘 하다보면 프로파일도 잘 된다고 한다. 처음부터 개발팀이 등록이 되어있고 프로파일도 잘 있고, Xcode도 세팅이 잘 되어 있다면 Xcode에서 자동으로 signing을 생성해준다.

그러나 Provisioning Profile이 꼬인 경우 처음부터 다시 한다는 마음으로 도전해야 한다.

오마이컴퍼니 앱의 경우 2~3년 묵은 프로젝트이므로 처음부터 잘 될거라고 생각하지 않았다.

Certificates, IDs & Profiles(인증서, ID 및 프로파일) : 앱 기능에 접근하고 앱을 배포하는 데 사용하는 인증서, 키, 식별자, 기기 및 Provisioning Profile(권한 설정 프로파일)을 관리할 수 있다.

애플 개발자 사이트 Certificates, IDs & Profiles 메뉴에서 Provisioning Profile을 만들어야 한다.

1. 인증서 발급

맥북 키체인 접근 -> 인증서 지원 -> 인증 기관에서 인증서 요청

Certificates 메뉴에 인증서 등록

2. Identifier 생성

App ID가 이미 등록되어 있어서 새로 만들 필요는 없었다.

새로 만든다면 Bundle ID를 잘 입력하고 Capabilities에서 Push Notifications 체크해서 생성한다.

3. Devices 등록

맥에 아이폰을 연결하고 finder를 실행한다. 아이튠즈는 사라졌다.

아이폰을 선택하고 모델명과 용량이 적힌 부분을 클릭하면 UDID가 나오는데 메모한다.

Devices 메뉴에서 UDID를 잘 입력하고 디바이스를 추가한다.

4. Profiles 생성

Profiles 메뉴에서 + 버튼을 누르고 개발용 / 배포용, 플랫폼을 선택할 수 있다.

다음 메뉴에서는 2에서 생성한 App ID를 선택한다.

다음은 맥북에서 만들고 Certificates 메뉴에 등록한 개발용 / 배포용 인증서를 선택한다.

배포용 인증서는 Xcode에서 자동으로 만들어줬다. (testflight 배포시 자동으로 Xcode에서 생성한 것으로 추정)

마지막으로 Provisioning Profile 이름을 적고 Generate!!

다운로드를 받고 잘 보관한다. 다시 다운로드 가능하다.

네이티브 앱 개발중단..

Flutter를 이용해 다시 앱을 만들기로 했다.

기존 앱의 가장 큰 문제는 유지보수가 어렵다는 것.

기본적인 웹뷰 기반 하이브리드 앱을 위해서 크로스플랫폼 프레임워크를 선택했다.

Flutter ??

비록 당신이 웹개발자라 할지라도 아름다운 모바일 애플리케이션을 만들 수 있게 지원하는 모바일 SDK

Flutter vs React Native

이미 Flutter 앱 개발이 끝난 상황이라 편파적일 수 있다.

Flutter

React Native

왜 Flutter를 선택했나요?

Flutter 개발환경 세팅하기

  1. iOS용 Flutter 앱을 개발하기 위해서는 Xcode가 설치된 맥을 준비한다.
  2. Android Studio를 설치한다. (VSCode를 사용해도 된다.)
  3. Flutter 환경변수 설정하기(MacOS의 기본 쉘이 bash에서 zsh로 변경되어 별도의 설정 필요)
  4. 터미널에서 ‘Flutter Doctor’ 를 입력해서 개발환경을 체크한다.

개발이슈들

1) Webview

webview_flutter 를 추가하고 오마이컴퍼니 웹사이트를 불러오니 다 완성된 것 같았다.

테스트를 하는 도중에 문제를 발견했는데 기본적인 JavaScript Alert 함수가 작동하지 않았다.

개발할 당시에는 아직 0.4 버전이었고 flutter_InappWebview 라는 더 성숙한 라이브러리가 있어서 교체했다.

현재는 1.0.7 버전이 나왔다. (물론 Alert 함수도 잘 작동된다.)

2) UserAgent

서버에서 현재 접속한 유저가 오마이컴퍼니앱이라는 것을 식별하기 위해서 UserAgent에 고유 식별자를 넣어주기로 했다.

InAppWebview의 크로스플랫폼 옵션들 중에 applicationNameForUserAgent 속성에 식별자를 넣어주면 appned 된다고 했다. 하지만 iOS에서 정상작동하지 않는 버그가 발생했고 어쩔 수 없이 다른 방법을 사용하기로 했다.

flutter_user_agent 라이브러리를 추가하고 InAppWebview를 불러오기 전에 UserAgent에 식별자를 넣고 InAppWebview UserAgent 속성에 넣어준다.

String _userAgent = '<unknown>';

Future<void> _getUserAgent(context) async {
    String userAgent, webViewUserAgent;

    try {
      userAgent = await FlutterUserAgent.getPropertyAsync('userAgent');
      await FlutterUserAgent.init();

      // append something
      webViewUserAgent = FlutterUserAgent.webViewUserAgent + 'your_specific_ua';

    } on PlatformException {
      userAgent = webViewUserAgent = '<error>';
    }

    _userAgent = webViewUserAgent;
 }

================================================================================
InAppWebView(
    initialUrl: _url,
    initialHeaders: {},
    initialOptions: InAppWebViewGroupOptions(
        crossPlatform: InAppWebViewOptions(
          userAgent: _userAgent,   // UserAgent 세팅

3) Android Intent 처리

urlScheme이 ‘intent://’ 로 넘어 올 경우 ??

flutter에는 intent라는 개념이 없고 native 코드에서 intent Scheme을 파싱하는 로직을 구현해야 한다.

참고 블로그

// MainActivity.kt

class MainActivity: FlutterActivity() {

    private val CHANNEL = "your_method_channel"

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->

            if(call.method == "getAppUrl") {
                try {
                    val url: String? = call.argument("url")
                    val intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                    result.success(intent.getDataString())
                } catch (e: URISyntaxException) {
                    result.notImplemented()
                } catch (e: ActivityNotFoundException) {
                    result.notImplemented()
                }
            }

            if(call.method == "getMarketUrl") {
                try {
                    val url: String? = call.argument("url")
                    val intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                    val scheme = intent.getScheme()
                    val packageName = intent.getPackage()

                    if(packageName != null)
                        result.success("market://details?id=" + packageName)
                } catch (e: URISyntaxException) {
                    result.notImplemented()
                } catch (e: ActivityNotFoundException) {
                    result.notImplemented()
                }
            }
        }
    }
}
// xxx.dart

static const platform = MethodChannel('your_method_channel');
===========================================================================
// if (!['http', 'https', 'javascript', 'chrome', 'data', 'file', 'about', 'market'].contains(uriScheme)) 인 경우 호출한다.
void _launchUrlScheme(String url) {
    _getAppUrl(url).then((value) async {
      if (await canLaunch(value)) {
        await launch(value);
      } else {
        final marketUrl = await _getMarketUrl(url);
        await launch(marketUrl);
      }
    });
  }

  Future<String> _getAppUrl(String url) async {
    if (Platform.isAndroid) {
      return await platform
          .invokeMethod('getAppUrl', <String, Object>{'url': url});
    } else {
      return url;
    }
  }

  Future<String> _getMarketUrl(String url) async {
    if (Platform.isAndroid) {
      return await platform
          .invokeMethod('getMarketUrl', <String, Object>{'url': url});
    } else {
      return url;
    }
  }

4) 팝업 윈도우

window.open(), target_blank 등은 InAppWebivew의 onCreateWindow에서 처리한다.

팝업을 생성하고 싶지 않으면 부모 웹뷰에 url을 로드하여 처리할 수도 있다.

onCreateWindow: (InAppWebViewController controller, CreateWindowRequest createWindowRequest) async {
	// iOS
  if(Platform.isIOS) {
    if(url.contains('abcdefg')) {
      _iOSPopupWindow(createWindowRequest);
      return true;
    } else { // 나머지 메인웹뷰에서 로드
      _isPopupOpen = false;
      _mainWebview = controller;
      _mainWebview.loadUrl(url: url);
      return false;
    }


  // Android
  if(Platform.isAndroid) {
    _androidPopupWindow(createWindowRequest);
    return true;
  }

  return false;
},
void _iOSPopupWindow(CreateWindowRequest createWindowRequest){
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          insetPadding: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 55.0),
          contentPadding: EdgeInsets.all(5.0),
          content: Container(
            width: MediaQuery.of(context).size.width,
            height: 580,
            child: InAppWebView(
              //initialUrl: createWindowRequest.url,
              windowId: createWindowRequest.windowId,
              initialOptions: InAppWebViewGroupOptions(
                crossPlatform: InAppWebViewOptions(
                  useShouldOverrideUrlLoading: true,
                  cacheEnabled: true,
                  javaScriptEnabled: true,
                  debuggingEnabled: true,
                ),
              ),
              onWebViewCreated: (InAppWebViewController controller) async {
                _popupWebview = controller;
                _isPopupOpen = false;
              },
              onLoadStart: (InAppWebViewController controller, String url) {
                print('_iOSPopupWindow - onLoadStart >>>>>> ' + url);
              },
              onLoadStop: (InAppWebViewController controller, String url) async {
                print('_iOSPopupWindow - onLoadStop >>>>>> ' + url);
              },
              onCloseWindow: (InAppWebViewController controller) {
                if (Navigator.canPop(context)) {
                  Navigator.pop(context);
                }
              },
            ),
          ),
        );
      },
    );
  }

5) 실시간 결제

오마이컴퍼니는 후원형 프로젝트와 증권형 프로젝트 모두 실시간 결제를 지원한다. 뱅크페이를 통해서 실시간 결제를 진행하는 방식이다. 모바일웹에서 뱅크페이로 이동하고 결제 후에 다시 웹사이트로 돌아온다.

앱에서 앱으로 이동하려면 appScheme을 설정해야 한다. 그렇게 해야 다시 돌아올 수 있다.

다시 돌아 왔을 때 어떤 링크로 돌아왔는지 확인하기 위해 uni_links 라이브러리를 사용했다.

initeState() 메소드에서 핸들링할 수 있다.

import 'package:uni_links/uni_links.dart';
================================================================
StreamSubscription _sub

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

    _sub = getLinksStream().listen((String link) async {
      try {
        if (link != null) {
          String decodedUrl = Uri.decodeComponent(link);
          Uri parsedUrl = Uri.parse(decodedUrl);
          String scheme = parsedUrl.scheme;

					// 사용 중인 PG와 관련된 소스코드 넣기
        }
      } on FormatException {}
    });
  }

6) Javascript 양방향 통신

6.1 InAppWebView에서 Javascript 함수 호출하기

InAppWebViewController _mainWebview;
================================================================================
Future<void> _evaluateJavascript(String functionName, dynamic param) async {
    String callScript = '$functionName(\'$param\');';
    await _mainWebview.evaluateJavascript(source: callScript);
}

6.2 서버에서 InAppWebView의 이벤트 핸들러 호출하기

예를 들어 프로필사진을 바꾼다고 할 때, 이미지 업로드를 해야 하는데 모바일앱에서 카메라권한 또는 갤러리권한을 허용한 유저만 해당 기능을 사용하게 해야 한다.

onWebViewCreated: (InAppWebViewController controller) {
  _mainWebview = controller;

  // 파일 업로드 이벤트핸들러
  _mainWebview.addJavaScriptHandler(handlerName: "[your-handler-name]", callback: (args) async {
    bool permissionStatus =  false;

    // 권한 승인여부 true / false 값
    permissionStatus = await checkPermission();

    if(!permissionStatus) {
      print('카메라 또는 사진권한을 허용해야 파일업로드를 할 수 있습니다.');
      return 'permissionDenied';
    } else {
      return 'permissionGranted';
    }
  });
},
window.addEventListener("flutterInAppWebViewPlatformReady", function () {
  window.flutter_inappwebview
    .callHandler("[your-handler-name]")
    .then(function (result) {
      if (result === "permissionGranted") {
        // 파일 업로드 로직 실행 위치
      } else if (result === "permissionDenied") {
        // 파일 업로드 로직 실행 x
      }
    });
});

7) iOS 권한 오류

AppStore Connect에 업로드할 때 발생하는 오류

사용하지 않는 권한을 사용한다고 메시지가 나온다.

아직 Flutter가 iOS 설정을 완전하게 지원하지 않는 부분이다.

Podfile을 재설정 하면 된다.

만약 카메라 권한을 사용하지 않는다면 ‘PERMISSION_CAMERA=0’ 만 넣으면 된다.

밑의 예는 ‘카메라’, ‘포토’, ‘알림’ 권한을 사용하겠다는 것이다.

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)

    target.build_configurations.each do |config|
          # You can remove unused permissions here
          # for more infomation: https://github.com/BaseflowIT/flutter-permission-handler/blob/develop/permission_handler/ios/Classes/PermissionHandlerEnums.h
          # e.g. when you don't need camera permission, just add 'PERMISSION_CAMERA=0'
          config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
            '$(inherited)',

            ## dart: PermissionGroup.calendar
            'PERMISSION_EVENTS=0',

            ## dart: PermissionGroup.reminders
            'PERMISSION_REMINDERS=0',

            ## dart: PermissionGroup.contacts
            'PERMISSION_CONTACTS=0',

            ## dart: PermissionGroup.camera
            # 'PERMISSION_CAMERA=0',

            ## dart: PermissionGroup.microphone
            'PERMISSION_MICROPHONE=0',

            ## dart: PermissionGroup.speech
            'PERMISSION_SPEECH_RECOGNIZER=0',

            ## dart: PermissionGroup.photos
            # 'PERMISSION_PHOTOS=0',

            ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
            'PERMISSION_LOCATION=0',

            ## dart: PermissionGroup.notification
            # 'PERMISSION_NOTIFICATIONS=0',

            ## dart: PermissionGroup.mediaLibrary
            'PERMISSION_MEDIA_LIBRARY=0',

            ## dart: PermissionGroup.sensors
            'PERMISSION_SENSORS=0'
          ]
        end
  end
end

8) Android 키스토어 설정

# key.properties
storePassword='your store password'
keyPassword='your key password'
keyAlias='your keyAlias'
storeFile='/your_Project/android/keystore.jks

9) 구글 로그인

우리 팀의 유능한 개발자J님이 웹에서 구글 로그인을 잘 만들어 놓았지만

구글이 웹뷰에서 Oauth 로그인을 원천적으로 막았다.

어쩔 수 없이 파이어베이스를 이용해여 구글 로그인을 하고

계정 정보를 자바스크립트 함수로 호출하는 방식으로 로그인을 구현했다.

배포하기

1) iOS

Xcode → Runner.xcworkspace 열기

Project Navigator에서 Runner 선택 → General 탭 선택 → Version / Build번호 변경 → Product → Archive → depoly → App Store Connect 업로드

2) Android

pubspec.yaml → version: 1.0.0+1 (버전네임+버전코드) 수정

터미널 → flutter build appbundle → Google Play Console 등록

기타