Swift로 UIKit을 사용해서 iOS 네이티브 앱을 개발하다 보면 3D 구현에 한계를 느끼게 된다.
반대로 Unity3D로만 게임이 아닌(non-game) 3D 앱을 개발하다 보면 (Bondee 같은 앱을 생각하면 상상이 쉽다.) 유니티의 뿌연 UI에 한계를 느끼게 된다.
결국 이를 극복하려면 각각의 장점만 취득할 수 있어야 하는데, 유니티는 이렇게 네이티브 iOS 앱 위에 라이브러리 형태로 유니티 뷰를 얹어서 사용할 수 있도록 Unity as a Library(UaaL)를 제공하고 있다.
이 글에서는 UaaL 자체에 대한 설명은 하지 않을 예정이다. 잘 설명된 아티클 몇 개만 첨부하고 바로 UaaL에서 iOS와 유니티 사이 데이터 통신을 어떻게 하면 편하게 구현할 수 있을지 얘기해보려 한다.
https://brunch.co.kr/@eunjin3786/259
위 예시들을 참고해 Swift 기반 iOS 프로젝트에 유니티를 성공적으로 안착시켰다면, 언젠간 유니티 뷰 위에 뜨는 iOS 뷰를 관리해야 할 필요성을 느끼게 된다. (UaaL로는 한 번에 한 유니티 뷰밖에 띄우지 못한다. 무조건 full view여야 한다.) 그건 이 글에서 설명해두었다.
https://welcometodannas.tistory.com/67
뷰까지 관리가 되고 나면 이제 iOS와 Unity 간 데이터 통신의 필요성을 느끼게 된다.
주고 받아야 할 데이터는 여러 형태가 될 수 있다.
iOS에서 어떤 버튼 액션이 발생했을 때 유니티에 액션이 발생했으니 UI를 변경하라고 전달해야 하고,
유니티를 서버 통신 작업 없이 철저히 View로만 사용한다고 가정했을 때, iOS에서 서버 통신을 끝내면 유니티에 그 데이터를 전달해주어야 하고,
반대로 유니티에서 뷰를 띄우기 위해 어떤 데이터가 필요해지면 그때그때 iOS에 데이터를 달라고 요청할 수 있어야 한다.
이런 니즈를 느끼기 시작하면 효율적으로 데이터를 주고 받을 수 있는 구조를 고민하게 된다.
데이터 전달하는 과정을 가장 간편하게 만들어 어느 뷰에서든 인스턴스나 다른 모든 것들에 신경 쓸 필요 없이 어떤 메서드 하나만 호출하면 유니티로, 혹은 iOS로 데이터가 전달될 수 있는 구조를 구축하고 싶었다.
그렇게 고민 끝에 지금의 구조를 구축하게 되었고, 그 이야기를 공유하려 한다.
UaaL에서 기본으로 제공하는 데이터 전달 방식
iOS -> Unity 데이터 전달 방식
사실 유니티가 제공하는 Uaal 프로젝트를 그대로 받아보면 iOS에서 유니티로 메시지를 전달하는 sendMsgToUnity 함수가 구현이 되어 있다.
- (void)sendMsgToUnity
{
[[self ufw] sendMessageToGOWithName: "Cube" functionName: "ChangeColor" message: "yellow"];
}
코드를 뜯어보면, 현재 띄워져 있는 유니티 Scene에서 Cube라는 이름의 게임 오브젝트를 찾아 그 오브젝트에 부착되어 있는 스크립트 속 ChangeColor 함수에 yellow라는 파라미터를 전달하도록 한 코드다.
실제로 Cube.cs 파일을 보면 newColor 문자열을 파라미터로 받는 ChangeColor 함수가 구현되어 있는 것을 볼 수 있다.
void ChangeColor(string newColor)
{
appendToText( "Chancing Color to " + newColor );
lastStringColor = newColor;
if (newColor == "red") GetComponent<Renderer>().material.color = Color.red;
else if (newColor == "blue") GetComponent<Renderer>().material.color = Color.blue;
else if (newColor == "yellow") GetComponent<Renderer>().material.color = Color.yellow;
else GetComponent<Renderer>().material.color = Color.black;
}
저 sendMsgToUnity는 iOS에서 색 바꾸기 버튼이 눌렸을 때 그 액션으로 부르고 있는 메서드다.
그런데, 하나 큰 문제가 있다.
만약 유니티 Cube.cs에서 ChangeColor 함수의 이름을 다른 것으로 바꾼다면?
혹은 게임오브젝트 이름을 Cube가 아닌 다른 것으로 바꾸게 된다면?
그럴 때마다 매번 iOS 쪽 코드인 sendMsgToUnity를 찾아와 값을 모두 바꿔줘야 할 것이다.
사실 엄연히 따지면 iOS는 유니티 쪽 메서드 이름이 어떻게 지어져 있는지에 관심을 가질 필요가 전혀 없어야 한다. 팀으로 개발하고 있다면, 쓸 데 없는 커뮤니케이션 비용이 발생할 수 있다.
Unity -> iOS 데이터 전달 방식
유니티에서 iOS로 데이터를 전달하는 방식 역시도 기본적으로 제공되는 파일에 구현되어 있긴 하다.
Cube.cs를 보면, 유니티에서 버튼을 띄우고 있고, 이 버튼이 클릭되면 showHostMainWindow라는 메서드를 호출하는데, 이는 iOS에 구현되어 있는 메서드다.
아래는 Cube.cs에서 일부 코드만 가져온 코드다.
#if UNITY_IOS || UNITY_TVOS
public class NativeAPI {
[DllImport("__Internal")]
public static extern void showHostMainWindow(string lastStringColor);
}
#endif
public class Cube : MonoBehaviour
{
void showHostMainWindow()
{
NativeAPI.showHostMainWindow(lastStringColor);
}
void OnGUI()
{
if (GUI.Button(new Rect(10, 300, 400, 100), "Show Main With Color", style)) showHostMainWindow();
}
}
extern 메서드를 활용해 objective-c 브릿지로 가는 showHostMainWindow를 직접 부르고 있다.
(브릿지에 대한 자세한 방법은 옛날에 쓴 글에서 설명해두었다.)
https://welcometodannas.tistory.com/65
이 방법의 가장 큰 문제는 유니티에서 부르고 싶은 iOS 메서드가 생길 때마다 objc 브릿지를 작성해주어야 한다는 것이다.
헤더 파일(NativeCallProxy.h)도 바꿔주어야 하고, objc 파일(NativeCallProxy.mm)도 바꿔주어야 한다.
// NativeCallProxy.h
#import <Foundation/Foundation.h>
@protocol NativeCallsProtocol
@required
- (void) showHostMainWindow:(NSString*)color;
@end
// NativeCallProxy.mm
#import <Foundation/Foundation.h>
#import "NativeCallProxy.h"
extern "C" {
void showHostMainWindow(const char* color) { return [api showHostMainWindow:[NSString stringWithUTF8String:color]]; }
}
이 방법에서 보완되어야 하는 것
결국 이렇게 구현된 것들을 보다 보면 개발에 여러 애로사항이 생기게 된다. 가장 큰 문제는 이거다.
- iOS에서 실제 구현된 메서드 이름을 Unity에서 알아야 한다는 것
- iOS의 메서드 이름이 변경되면 Unity에서도 바꿔줘야 한다. -> 불필요한 커뮤니케이션 비용이 발생한다.
- 반대의 경우(Unity -> iOS)도 마찬가지다.
- 데이터 통신이 많아질수록 헤더, 브릿지 파일이 방대해진다는 것
- 헤더, 브릿지 파일은 유니티 플러그인에 삽입된 Objective-C 코드를 수정해야 한다.
- Objective-C는 iOS 개발자가 관리하는 일이 많은데, iOS 개발자가 유니티 코드 베이스에 접근해야 하는 일이 많아지면 개발 피로도가 증가한다.
- 헤더, 브릿지 코드를 수정하고 나면 유니티 빌드를 하고, iOS 빌드를 재차 해서 테스트 해야 하기 때문에 피로도가 높다.
- string 타입이 아닌 다른 데이터 타입(object)의 데이터 전송은 검증되지 않았다는 것
따라서 자연스레 아래에 나열된 것들을 바라게 된다.
- iOS나 Unity에서 메서드 이름이 바뀌는 것에 영향 받지 않을 것 (데이터 전달에만 신경쓸 수 있게)
- 헤더, 브릿지 파일을 매번 관리할 일이 없게 많들 것
- 어느 Scene, ViewController, View에서나 데이터 전달 코드를 호출할 수 있을 것
- string이 아닌 모든 데이터 타입을 주고 받을 수 있게 할 것
이 모든 걸 고려해 아키텍쳐를 설계했다.
고민 끝에 설계한 데이터 전송 아키텍쳐
데이터 전송 메서드 통합
가장 우선적으로 되어야 할 작업은 데이터를 전송해야 할 일이 있을 때마다 헤더, 브릿지 파일이 변경되어야 하는 걸 막는 것이었다.
이 파일들은 유니티 플러그인에 넣어놓은 파일들에 접근해 바꿔줘야 하는데, 수정할 때마다 유니티 빌드를 하고, iOS 빌드도 한 후 테스트를 할 수 있었다. 한 번 적어놓으면 이 파일을 최대한 안 건들고 싶었다.
그래서 데이터를 전달 역할을 하는 메서드 하나로 모든 데이터 전달을 처리하도록 설계했다.
iOS에서 Unity로 데이터 전달은 아래 코드로 통합했다.
사용하는 유니티 Scene에 NativeController라는 스크립트를 부착한 게임 오브젝트를 만들고, 메시지를 전달하는 형태다.
- (void)sendMsgToUnity:(NSString*)message {
[[self ufw] sendMessageToGOWithName: "NativeController" functionName: "ReceiveFromNative" message: (const char*)[message UTF8String]];
}
public class NativeController : MonoBehaviour {
public void ReceiveFromNative(string message)
{
// Do something
}
}
Unity에서 iOS로 데이터 전달하는 역할을 하는 .h, .mm 파일은 아래처럼 바꿔주었다.
@protocol NativeCallsProtocol
@required
- (NSString*) sendMessageFromUnity:(NSString*)serializedMessage;
@end
extern "C" {
const char* SendMessageFromUnity(const char* message) {
const char* receivedJson = (const char*)[[api sendMessageFromUnity:[NSString stringWithUTF8String:message]] UTF8String];
return receivedJson;
}
}
이렇게 되면, 모든 종류의 데이터 전송이 이 하나의 메서드들만으로 커버된다.
데이터 전송 케이스를 추가할 때마다 여러 파일이나 이름을 복잡하게 바꿔 줄 필요가 없다.
다만, 이제 데이터 전송 케이스가 메서드 이름으로 구분되지 않으므로 전송하는 메시지에 담아 보내야 한다.
이런 생각을 하다 보면 자연스레 object 형태로 message 보내는 것을 구상하게 된다.
json으로 데이터 전송
object로 메시지를 구성하려면 serialize 한 후 json으로 데이터를 전송하는 방법을 생각해 볼 수 있다.
보내는 쪽에서 serialize 해서 보내주면 받는 쪽에서 deserialize해서 받아 보는 방식이다.
위에서 데이터 전송 케이스가 분리되어야 함을 알았으니, 뼈대가 되는 객체는 아래처럼 생겨야 할 것이다.
어떤 상황에 발생시킨 데이터 전송인지 명시하는 type과 실제 데이터를 담는 data로 구성했다.
struct GenericRequest<T: Codable>: Codable {
var type: String
var data: T?
}
전송해야 하는 데이터의 용량이 큰 경우, protobuf로 관리해 보는 방법도 있다.
Type 같은 경우는 iOS 코드와 유니티 코드 모두에서 enum으로 관리한다.
iOS -> Unity 데이터 전송 메커니즘
json으로 데이터 전송 메서드를 만들었다면, 이제 남은 건 iOS 코드 어디서든 데이터 전달을 할 수 있게끔 하는 작업이다.
(무조건 특정 인스턴스나 뷰에 접근해서 데이터 전달을 해야 하면 매우 불편하고 여러 제약이 있을 것이다.)
그래서 이 작업을 해줄 수 있는 iOS-Unity Helper를 만들었다.
데이터와 있는 경우, 없는 경우로 나누어 어디서든 IosUnityHelper.sendMessageToUnity(type: foo, body: foo)로 호출할 수 있도록 만들었다.
class IosUnityHelper: NSObject {
// 데이터가 없는 경우 (상태만 전달하면 되는 경우)
public static func sendMessageToUnity(type: IosToUnityHeader) {
switch type {
case .ZoomOut,
sendRequest(type: type.rawValue)
default:
break
}
}
// 데이터가 있는 경우
public static func sendMessageToUnity<T>(type: IosToUnityHeader, body: T) {
switch type {
case .ZoomIn:
sendRequest(type: type.rawValue, body: body as? Zoom)
default:
break
}
}
}
sendRequest() 부분도 역시 데이터가 있는 경우, 없는 경우로 나눠 JSON 인코딩을 구현했다.
extension IosUnityHelper {
private static func sendRequest(type: String) {
let encoder = JSONEncoder()
do {
let data = GenericRequest<Bool>(type: type)
let request = String(data: try encoder.encode(data), encoding: .utf8)
UnityManager.sharedInstance()?.sendMsg(toUnity: request)
return
} catch {
// error handling
return
}
}
private static func sendRequest<T: Codable>(type: String, body: T?) {
let encoder = JSONEncoder()
do {
let data = GenericRequest(type: type, data: body)
let request = String(data: try encoder.encode(data), encoding: .utf8)
UnityManager.sharedInstance()?.sendMsg(toUnity: request)
return
} catch {
// error handling
return
}
}
}
sendRequest가 불리면, 앞에서 만들었던 UnityManager의 sendMsgToUnity()가 불리고, 유니티에 데이터가 전달되는 구조다.
Unity -> iOS 데이터 수신 메커니즘
iOS에서 유니티로 성공적으로 데이터를 보냈다면,
Unity에서 iOS로 보내오는 데이터를 받을 수도 있어야 한다.
이건 Unity-iOS Helper를 만들어 구현했다.
언제 Unity가 iOS로 데이터를 보내올지 계속 대기할 필요는 없고, 데이터 수신이 발생하면 모든 이벤트는 이 Helper에서 핸들링된다.
따라서 싱글톤으로 설계했다.
유니티에서 데이터를 받으면 deserializeJsonFromUnity가 호출된다. (UnityManager의 sendMessageFromUnity가 호출되면 deserializeJsonFromUnity 하도록 설계해놓았다.)
- (NSString*)sendMessageFromUnity:(NSString*)serializedMessage {
NSString* json = [[UnityIosHelper shared] deserializeJsonFromUnityWithJson:serializedMessage];
return json;
}
그럼, 가장 먼저 어떤 요청을 하는 메시지인지 JSON을 파싱해 type을 확인한다.
struct GenericResponse<T: Codable>: Codable {
var type: String
var status: Bool?
var data: T?
}
@objc(UnityIosHelper)
class UnityIosHelper: NSObject {
@objc public static let shared = UnityIosHelper()
private let decoder = JSONDecoder()
/// Unity -> iOS 받은 메시지 파싱
@objc public func deserializeJsonFromUnity(json: String) -> String {
guard let jsonData = json.data(using: .utf8) else {
Log.error("Invalid Json String %@", json)
return generateFailedResponse()
}
do {
let type = try decoder.decode(MessageType.self, from: jsonData).type
var response: String?
if UnityToiOS.isMember(type: type) {
try handleMessageFromUnity(type: type, jsonData: jsonData)
response = try generateResponse(type: type, status: true)
}
if let returnValue = response {
return returnValue
}
} catch {
Log.error("deserializeJsonFromUnity %@", json, error as NSError)
}
return generateFailedResponse()
}
}
extension UnityIosHelper {
private func generateResponse(type: String, status: Bool) throws -> String? {
let data = GenericResponse<Bool>(type: type, status: status)
let encoder = JSONEncoder()
return String(data: try encoder.encode(data), encoding: .utf8)
}
private func generateFailedResponse() -> String {
return "{ \"status\": false }"
}
}
그 후 각 type에 맞춰 원하는 액션을 실행한다.
@objc(UnityIosHelper)
class UnityIosHelper: NSObject {
@objc public static let shared = UnityIosHelper()
private let decoder = JSONDecoder()
private func handleMessageFromUnity(type: String, jsonData: Data) throws {
switch UnityToiOSHeader(rawValue: type) {
case .SomeCallWithoutData:
// Do something
case .SomeCallWithData:
guard let data = try decoder.decode(GenericResponse<CustomType>.self, from: jsonData).data else {
throw
}
// Do something with data
default:
// handle error
throw
}
}
}
이 과정을 통해 iOS -> Unity로 전송하는 부분은 어디서든 IosUnityHelper.sendMessageToUnity(type: , data: )만 호출하면 가능하게,
그리고 Unity에서 iOS로 받는 부분은 파일 하나에서 관리되게 구현했다.
+) string alloc 관련 트러블슈팅
데이터를 주고 받는 과정에서 string alloc 관련 에러가 났다.
이미 alloc되지 않은 메모리를 free 하려 한다는 에러가 자꾸 나서 해당 라인을 임의로 주석처리해봤더니 제대로 동작하는 증상이었다.
https://stackoverflow.com/questions/7322503/pinvoke-how-to-free-a-mallocd-string
이 글을 참고해서 NativeCallProxy.mm에서 alloc 후 문자열을 리턴하고, free 하는 함수를 만들어서 명시적으로 호출해주니 에러가 해결되었다.
You should marshal returned strings as IntPtr, otherwise the CLR may free the memory using the wrong allocator, potentially causing heap corruption and all sorts of problems.
Ideally your C dll should also expose a FreeText function for you to use when you wish to free the string. This ensures that the string is deallocated in the correct way (even if the C dll changes).
extern "C" {
const char* SendMessageFromUnity(const char* serializedMessage) {
if (serializedMessage == NULL) {
return "{\"status\": false}";
}
const char* receivedJson = (const char*)[[api sendMessageFromUnity:[NSString stringWithUTF8String:serializedMessage]] UTF8String];
char* ptrReturnValue = (char*) malloc(strlen(receivedJson) + 1);
strcpy(ptrReturnValue, receivedJson);
return ptrReturnValue;
}
void FreePointer(char* ptr) {
free(ptr);
}
}
'iOS > Unity + iOS' 카테고리의 다른 글
[iOS+Unity] 유니티 뷰 위에 Native iOS UI 얹고 효율적으로 관리하기 (0) | 2023.04.08 |
---|---|
Unity에서 Swift 코드 쓰기 & iOS native API 사용하기 (feat. HealthKit) (1) | 2023.04.08 |