[Unity] Swift로 네이티브 플러그인 만들기 (feat. AVAudioSession)

2024. 4. 13. 19:55·Unity 3D
반응형

회사에서 아고라라는 음성 SDK를 사용하는데, iOS에서 블루투스 이어폰이 제대로 안 되는 현상이 생겼다. 보통은 Object-C로 네이티브 플러그인을 만드는데, Object-C 자료도 점점 노후화되는 것 같아, 이번 기회에 Swift로 네이티브 플러그인을 만들어 봤다.

 

프로세스의 각 단계에 대한 코드 예제를 참고하여 이해에 도움이 됐으면 한다. 오류가 있거나 더 좋은 방법이 있으면 언제나 피드백해주시면 감사하겠습니다:)

 

저는 Swift나 Object-C에 대한 전문가가 아닌, Unity를 사용하는 클라이언트 프로그래머로서 Swift를 사용하여 네이티브 플러그인을 구현하는 방법을 연구하고 적용해 본 결과를 공유하기 위해 이 글을 작성했습니다.

 

 

유니티는 Swift를 지원하는가 ?


유니티 네이티브 플러그인 관련 매뉴얼

지원하지 않는다. 공식적으로 Swift로 작성된 네이티브 플러그인을 지원하지 않는다. 그래서 유니티와 Swift 사이에 브리지가 필요하다. 다행히도 한 프로젝트에서 Swift와 Object C를 동시에 사용할 수 있고, 서로 참조도 가능하다. 유니티에서 Object-C로 작성된 네이티브 플러그인을 지원하는 점을 이용해서 플러그인을 만들 수 있다.

 

 

Why Swift ?


여러 가지 문서를 봐도 Apple이 Object-C에서 Swift을 사용하도록 권장(?)하는 것 같다. 인터넷이나 애플 외 라이브러리의 관련 문서를 보면 Object-C의 경우 슬슬 오래된 글이 많이 보인다. 공식 문서 같은 경우, 애플이 Object-C와 Swift 모두 제공하고는 있지만, Swift를 사용하여 네이티브 플러그인을 구현하는 것도 해볼 만하다고 생각한다.


Object-C에 익숙하고 Swift를 사용할 이유가 없는 사람이라면 Object-C를 선택하는 것이 훨씬 경제적이고, 올바른 선택이라고 본다. 하지만 네이티브 플러그인 제작이 처음인 분들은 Swift로 시작해 보는 것도 나쁘지 않은 선택인 것 같다.

 

 

구현


Swift Project

iOS 플랫폼으로 프레임워크 프로젝트를 하나 만들어 준다.

다른 방법으로 Swift 패키지를 만들어서 구현하는 방법도 있었는데, Xcode에서 패키지로 Xcode project를 만드는 걸 더 이상 지원하지 않는 것 같다. 방법을 아시는 분은 알려주시면 감사하겠습니다.

Bridge

총 3개 파일을 생성한다. 이름은 나중에 유니티 빌드 자동화를 위해 사용할 건데, 적당한 이름으로 지어주시면 됩니다.

 

Header file

일반적으로 신경 쓰지 않아도 된다. 만들기만 하면 자동으로 작성된다.

 

Swift file

Swift 파일은 메인 로직이다. 필요에 따라서 구현하면 된다.

NSObject는 Object-C의 최상위 클래스이다. 이를 상속받고, @objc 특성(attribute)을 함수와 클래스 앞에 명시적으로 표기해 줌으로써, Object-C에서 Swift 코드를 사용할 수 있도록 한다.

다음은 AVAudioSession을 다루는 간단한 예제이다.

@objc public class AudioManager : NSObject{

    @objc public static let shared = AudioManager()

    @objc public func SetAudioCategory(category: String)
    {
        let audioSession = AVAudioSession.sharedInstance()
        do
        {
            let audioCategory = AVAudioSession.Category.fromString(category)
            if (audioCategory == nil)
            {
                print("AudioManager : Category not exist - " + category)
                return
            }

            try audioSession.setCategory(audioCategory!)
        } catch
        {
            print("AudioManager : Error setting audio category - " + category)
        }
    }

    @objc public func SetAudioMode(mode: String)
    {
        let audioSession = AVAudioSession.sharedInstance()
        do
        {
            let audioMode = AVAudioSession.Mode.fromString(mode)
            if (audioMode == nil)
            {
                print("AudioManager : Mode not exist - " + mode)
                return
            }

            try audioSession.setMode(audioMode!)
        } catch
        {
            print("AudioManager : Error setting audio mode - " + mode + "error" + error.localizedDescription)
        }
    }

    @objc public func TestParameterConnection(text: String)
    {
        print("Connection Success - " + text);
    }

    @objc public func GetCurrentAudioMode() -> String
    {
        let audioSession = AVAudioSession.sharedInstance()
        print("GetCurrentAudioMode - " + audioSession.mode.toString)
        return audioSession.mode.toString
    }

    @objc public func GetCurrentAudioCategory() -> String
    {
        let audioSession = AVAudioSession.sharedInstance()
        print("GetCurrentAudioCategory - " + audioSession.mode.toString)
        return audioSession.category.toString
    }

    @objc public func SetEnableHapticDuringRecording(isEnable: Bool)
    {
        let audioSession = AVAudioSession.sharedInstance()

        if #available(iOS 13.0, *) {
            do
            {
                try audioSession.setAllowHapticsAndSystemSoundsDuringRecording(isEnable)
            }
            catch{
                print("AudioManager: failed to SetEnableHapticDuringRecording");
            }
        }
    }
}

 

Object-C file

a. Swift와 Object-C를 이어주는 브리지 파일이다.
b. 파일 확장자를 .mm으로 바꾼다.
c. .mm는 Object-C와 C++를 한 파일에서 동시에 사용할 수 있게 해주고, .m는 Swift와 Object-C를 사용할 수 있는 파일이다. extern "C"같은 기능을 사용하기 위해서 C++ 기능이 필요하기 때문에 .mm을 사용한다.
d. #include "{framworkName}/{headerfileName}"를 이용해서 Swift 파일을 불러올 수 있다.

#import <Foundation/Foundation.h>
#include "AudioManager/AudioManager-Swift.h"

extern "C"
{

#pragma mark - TEST FUNCTIONS
void TestConnection(){
    NSLog(@"Connected");
}

void TestParameterConnection(const char *parameter) {
    NSString *parameterString = [NSString stringWithUTF8String:parameter];
    [[AudioManager shared] TestParameterConnectionWithText:parameterString];
}

#pragma mark - BridgeFunctions
void SetAudioCategory(const char *category){
    NSString *parameterString = [NSString stringWithUTF8String:category];
    [[AudioManager shared] SetAudioCategoryWithCategory:parameterString];
}

void SetAudioMode(const char *mode){
    NSString *parameterString = [NSString stringWithUTF8String:mode];
    [[AudioManager shared] SetAudioModeWithMode:parameterString];
}

char * GetCurrentAudioMode(){
    NSString* audioMode = [[AudioManager shared] GetCurrentAudioMode];
    const char *mode = [audioMode UTF8String];
    return strdup(mode);
}

char * GetCurrentAudioCategory(){
    NSString* audioCategory = [[AudioManager shared] GetCurrentAudioCategory];
    const char *category = [audioCategory UTF8String];
    return strdup(category);
}

void SetEnableHapticDuringRecording(BOOL setEnable){
    [[AudioManager shared] SetEnableHapticDuringRecordingWithIsEnable:setEnable];
}
}

이 부분이 조금 까다롭다. 위 예제에 보면, GetCurrentAudioCategory는 Swift에서는 String을 반환하지만, NSString을 받는다. 하지만 내가 알기로는 C#은 NSString을 인식할 수 없기 때문에 NSString을 char로 변환해야 합니다. 형식을 잘못 캐스팅하면 프리즈나 충돌이 생기기 때문에 주의해야 한다. 타입 캐스팅에 주의해야 하는데, 저 같은 경우 NSString을 UTF8String 인코딩으로 변환한 다음 반환했습니다.


매우 혼란스러운 개념인 것 같다. NSString이 무엇인지, 애초에 뭐 때문에 만들어졌는지 아직 100% 이해를 못 했다. 일단 내가 아는 건, NSString이 Object-C에서 사용되며, 여기서 char *는 C에서 일반적으로 사용된다는 것이다.

 

 

빌드

이제 Xcode에서 프레임워크를 빌드해서, 유니티 프로젝트에 넣으면 된다.

주의할 건, Xcode에서 빌드할 때 타겟 플랫폼을 시뮬레이터가 아닌 Any iOS devices로 돼있는지 확인해야 한다.

빌드 폴더로 가면 .framework 파일을 찾아서 유니티 프로젝트에서 넣어준다.

 

 

Unity Integration

이제 유니티로 넘어가서, 브리지 파일을 만들어주면 된다.

public enum AudioSessionCategory : int
{
    ambient,
    multiRoute,
    playAndRecord,
    playback,
    record,
    soloAmbient,
}

public enum AudioSessionMode : int
{
    Default,
    gameChat,
    measurement,
    moviePlayback,
    spokenAudio,
    videoChat,
    videoRecording,
    voiceChat,
    voicePrompt
}

public class iOSAudioManager
{
    #region Connection Test Functions
    [DllImport("__Internal")]
    private static extern void TestConnection();
    [DllImport("__Internal")]
    private static extern void TestParameterConnection(string mode);
    #endregion

    [DllImport("__Internal")]
    private static extern void SetAudioCategory(string category);
    [DllImport("__Internal")]
    private static extern void SetAudioMode(string mode);

    [DllImport("__Internal")]
    private static extern string GetCurrentAudioMode();
    [DllImport("__Internal")]
    private static extern string GetCurrentAudioCategory();
    [DllImport("__Internal")]
    private static extern void SetEnableHapticDuringRecording(bool isEnable);

    public static void SetIOSAudioMode(AudioSessionMode mode)
    {
        Debug.Log($"iOSAudioManager : SetIOSAudioMode - {mode.ToString()}");
        SetAudioMode(mode.ToString());
    }

    public static void SetIOSAudioCategory(AudioSessionCategory category)
    {
        Debug.Log($"iOSAudioManager : SetIOSAudioCategory - {category.ToString()}");
        SetAudioCategory(category.ToString());
    }

    public static AudioSessionMode GetCurrentIOSAudioMode()
    {
        string currentMode = GetCurrentAudioMode();

        var result = Enum.TryParse(typeof(AudioSessionMode), currentMode, out var mode);
        if (!result)
        {
            Debug.LogWarning($"iOSAudioManager : GetCurrentAudioMode - {currentMode} is not a valid AudioSessionMode");
            return AudioSessionMode.Default;
        }

        return (AudioSessionMode)mode;
    }

    public static AudioSessionCategory GetCurrentIOSAudioCategory()
    {
        string currentCategory = GetCurrentAudioCategory();

        var result = Enum.TryParse(typeof(AudioSessionCategory), currentCategory, out var category);
        if (!result)
        {
            Debug.LogWarning($"iOSAudioManager : GetCurrentAudioCategory - {currentCategory} is not a valid AudioSessionCategory");
            return AudioSessionCategory.soloAmbient;
        }

        return (AudioSessionCategory)category;
    }

    public static void SetEnableIOSHapticDuringRecording(bool isEnable)
    {
        SetEnableHapticDuringRecording(isEnable);
    }
}

 

 

Build Automation


유니티에서 앱을 빌드할 때마다 프레임워크를 넣고 옵션 키는 건 너무 귀찮다. 시간을 절약하기 위해 빌드 자동화 프로세스를 만들어 보자.


저는 빌드할 때 xcode 프로젝트에 프레임워크가 이미 포함되어 있고 서명되어 있었다. 그럼, xcode에 파일을 추가하는 부분은 건너뛰시면 됩니다. (FRAMERWORK_SEARCH_PATHS 속성이 값에 기록된 디렉토리에서 모든 프레임워크를 자동으로 찾는 것 같다). 중복으로 프로젝트에 프레임워크를 넣으면, duplicate 오류가 날 거다.

 

그렇지 않은 경우 PostProcessBuild 스크립트를 작성하여 Xcode 프로젝트에 플러그인을 수동으로 포함하세요.

public static class PostBuildScript
{
    [PostProcessBuild(1)]
    public static void OnPostProcessBuild(BuildTarget buildTarget, string buildPath)
    {
        if (buildTarget != BuildTarget.iOS) return;

        var projPath = PBXProject.GetPBXProjectPath(buildPath);
        var proj = new PBXProject();
        proj.ReadFromFile(projPath);

        var targetGuid = proj.GetUnityMainTargetGuid();

        proj.SetBuildProperty(targetGuid, "ENABLE_BITCODE", "NO");

        proj.SetBuildProperty(targetGuid, "SWIFT_OBJC_BRDIDGING_HEADER", "Libraries/Plugins/AudioManager-Bridging-Header.h");
        proj.SetBuildProperty(targetGuid, "SWIFT_OBJC_INTERFACE_HEADER_NAME", "AudioManager-Swift.h");

        proj.AddBuildProperty(targetGuid, "LD_RUNPATH_SEARCH_PATHS", "@executable_path/Frameworks $(PROJECT_DIR)/lib/$(CONFIGURATION) $(inherited)");
        proj.AddBuildProperty(targetGuid, "FRAMERWORK_SEARCH_PATHS",
            "$(inherited) $(PROJECT_DIR) $(PROJECT_DIR)/Frameworks");
        proj.AddBuildProperty(targetGuid, "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES", "YES");
        proj.AddBuildProperty(targetGuid, "DYLIB_INSTALL_NAME_BASE", "@rpath");
        proj.AddBuildProperty(targetGuid, "LD_DYLIB_INSTALL_NAME",
            "@executable_path/../Frameworks/$(EXECUTABLE_PATH)");
        proj.AddBuildProperty(targetGuid, "DEFINES_MODULE", "YES");
        proj.AddBuildProperty(targetGuid, "SWIFT_VERSION", "4.0");
        proj.AddBuildProperty(targetGuid, "COREML_CODEGEN_LANGUAGE", "Swift");

        const string frameworkName = "AudioManager.framework";
        var frameworkPath = Path.Combine("Plugins", frameworkName);
        string fileGuid = proj.AddFile(frameworkPath, Path.Combine("Frameworks", frameworkPath), PBXSourceTree.Sdk);
        proj.AddFileToEmbedFrameworks(targetGuid, fileGuid);
        proj.SetBuildProperty(targetGuid, "LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks");

        proj.WriteToFile(projPath);
    }
}

사실 대부분은 xcode가 알아서 잘해주는 것 같은데, 이 부분이 중요한 것 같다. 헤더 파일의 경로와 이름을 명시해 주는 곳이다.

proj.SetBuildProperty(targetGuid, "SWIFT_OBJC_BRDIDGING_HEADER", "Libraries/Plugins/AudioManager-Bridging-Header.h");
proj.SetBuildProperty(targetGuid, "SWIFT_OBJC_INTERFACE_HEADER_NAME", "AudioManager-Swift.h");

 

 

후기


이번 글 작성하는 과정은 쉽지는 않았지만, 굉장히 보람 있는 과정이었다. 보통 Object-C를 사용하여 네이티브 플러그인을 만들었지만, 정보가 점점 노후화 되어가고 있었고, 사람들도 iOS 개발에 있어 Object-C에서 Swift로 바꾸고 있는 추세인 것 같다. 저는 이번이 Swift에 대해 연구할 수 있는 좋은 기회라고 생각했습니다. Swift와 Unity의 통합에 대한 정보는 부족하고 오래된 경우가 많았습니다. 하지만 많은 시행착오 끝에 Swift와 Unity를 연결하는 다리를 만들 수 있었습니다. 이 가이드가 같은 여정에 있는 다른 개발자에게 도움이 되고 시간을 절약할 수 있기를 바랍니다.

 

 

Reference


https://docs.unity3d.com/kr/2022.3/Manual/NativePlugins.html

 

네이티브 플러그인 - Unity 매뉴얼

Unity는 C, C++, Objective-C 등으로 작성할 수 있는 네이티브 코드의 라이브러리인 네이티브 플러그인을 지원합니다. 플러그인은 C#으로 작성한 코드가 이러한 라이브러리에서 함수를 호출하게 해줍

docs.unity3d.com

https://developer.apple.com/documentation/objectivec/nsobject

 

NSObject | Apple Developer Documentation

The root class of most Objective-C class hierarchies, from which subclasses inherit a basic interface to the runtime system and the ability to behave as Objective-C objects.

developer.apple.com

https://developer.apple.com/documentation/avfaudio/avaudiosession

 

AVAudioSession | Apple Developer Documentation

An object that communicates to the system how you intend to use audio in your app.

developer.apple.com

https://medium.com/@rolir00li/integrating-native-ios-code-into-unity-e844a6131c21

 

Integrating native iOS code into Unity

Ever wondered how you can add Unity support to your native iOS Framework? Here’s how…

medium.com

 

반응형

'Unity 3D' 카테고리의 다른 글

Unity Addressable : MissingMethodException 해결법  (0) 2025.03.25
[Unity] Advanced Inputfield 사용기  (6) 2024.11.27
[Unity] 에디터 자동 컴파일 (Refresh) 끄기  (0) 2024.01.24
[Unity] Git으로 Custom Package 만들기  (0) 2024.01.21
[Unity] 텍스트 채팅을 구현하며 생긴 일  (2) 2024.01.18
'Unity 3D' 카테고리의 다른 글
  • Unity Addressable : MissingMethodException 해결법
  • [Unity] Advanced Inputfield 사용기
  • [Unity] 에디터 자동 컴파일 (Refresh) 끄기
  • [Unity] Git으로 Custom Package 만들기
DorokDorok
DorokDorok
문제해결을 해본 사람은 디테일을 안다!
    반응형
  • DorokDorok
    도록도록도로록
    DorokDorok
  • 전체
    오늘
    어제
    • Category (19)
      • Unity 3D (7)
      • 개발 이야기 (5)
      • 기타 (1)
      • AWS (1)
      • 포트폴리오 (3)
      • 깜지 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • 포트폴리오 및 개인 공부 일기장
  • 인기 글

  • 태그

    NestJS
    커스텀패키지
    노션
    ios
    유니티
    inputField
    어셈블리정의
    네이티브 플러그인
    cancellationtoken
    포트폴리오
    unity
    missingmethodexception
    리소스업로더
    티스토리챌린지
    tmpro
    assembly definition
    오블완
    advanced inputfield
    Auto Complie
    원자연산
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
DorokDorok
[Unity] Swift로 네이티브 플러그인 만들기 (feat. AVAudioSession)
상단으로

티스토리툴바