블로그

  • Override app delegate in Unity for iOS and macOS (1/4)

    http://blog.eppz.eu/override-app-delegate-unity-ios-macos-1/

    • workflow
    • advanced
    • code design
    • github
    • ios
    • macos
    • understanding
    • unity
    • unity3d
    • workflow
    • march 2, 2017

    When it comes to create native plugins for Unity, often the plugins need customize behaviour how the application starts / launches by override app delegate (see UIApplicationDelegate for iOS and NSApplicationDelegate for macOS). Opening app via Push Notifications, URL Schemes, Documents, User activities, or even via WatchKit events are all great examples of this.

    TL;DR

    You may get the example project immediately by heading to GitHub (in that case you should really be aware of the folder structure at the end of the article, as there are numerous sub-projects can be found all around). However, I strongly recommend to take the next 15 minutes to understand what is going on, and read along. See Unity.Blog.Override_App_Delegate at GitHub

    Override app delegate in iOS Unity player (Poor man’s method)

    Like many things in the Unity ecosystem, this feature is also heavily underdocumented. However, if you take a closer look to a Unity iOS player Xcode project, you can find that Unity provided some tools that can help.

    There is an external constant called AppControllerClassName can be found in every main.mm of every iOS Unity player Xcode project. This class name will be passed as the app delegate class to UIApplicationMain. That means that UIApplication (or any principal class given in Info.plist) will create an instance of that delegate (that should conform to UIApplicationDelegate), and call the given template methods throughout the application lifecycle.

    There you can find the only piece of documentation about this thing in that single comment line.

    Unity also provided a macro called IMPL_APP_CONTROLLER_SUBCLASS(className) that creates a tiny little category, which overrides the AppControllerClassName constant as soon as it gets loaded (along all the other classes).

    In short, if you put this file OverrideAppDelegate.m into your Plugins folder, you can essentially extend / override app delegate this way.#import “UnityAppController.h”@interface OverrideAppDelegate : UnityAppController@endIMPL_APP_CONTROLLER_SUBCLASS(OverrideAppDelegate)@implementation OverrideAppDelegate-(BOOL)application:(UIApplication*) application didFinishLaunchingWithOptions:(NSDictionary*) options{ NSLog(@”[OverrideAppDelegate application:%@ didFinishLaunchingWithOptions:%@]”, application, options); return [super application:application didFinishLaunchingWithOptions:options];}@end See OverrideAppDelegate.m at GitHub

    This can be a quick solution when your project has a single iOS plugin intended to override app delegate. But if you are about to use more iOS plugins that uses the same technique, one or the other will be broken. (Unfortunately) you can find countless Unity plugins on GitHub using the very same technique.

    You may create an override chain of plugins, but if you are conflicting with a closed source iOS plugin binary that uses this technique, then you basically can’t (unless you crack it).

    Unity Plugin workflow considerations when override app delegate

    To have a maintainable plugin, the first consideration is being totally encapsulated. It should not make a single assumption about the application, also it should not change a single thing in the application architecture itself. In the example above, you make two assumptions, both can be wrong. You assume that the base class of the app is UnityAppController, then you are assume that the value of AppControllerClassName is not gonna be changed after you have changed so.

    So in general, if you make such assumptions, the application may break the plugin (often the case when using multiple plugins), or the plugin may break the app (often the case when the app development progresses, but the plugin does not follow).

    I found that the most common consideration people pass over during plugin development is being compatible with other plugins (!). You may define a new base class for the main Unity controller, but if you are using another plugin that essentially does the same, you will break its intended behaviour.

    Almost the same considerations lead to the Unity Android Plugin Architecture in the article Unity Android plugin tutorial (2/3) Project setup and workflow, that extends behaviour without overriding application’s main Activity. Two or more plugins wanting to do the same could also create such conflicting situations.

    Ultimately this mindset leads to a healthier Unity plugin ecosystem in the long run.

    Unity Plugin architectures for iOS and macOS

    On iOS, Unity plugins can be packed / compiled along with the rest of the application code. They can be added in form of native classes, static or dynamic libraries (Libraries or Frameworks using iOS terms). That means native plugin classes gets loaded alongside the application classes. As application launch happens after the classes gets loaded, iOS plugins can extend application launching behaviours more easily.

    On macOS, however, Unity asks for plugins in form of bundles (following the general macOS plugin pattern). That means that plugin code will be loaded by the Objective-C application runtime (probably using an :[NSBunde load]: call). As a consequence, the plugin code gets loaded after the application has finished launching, seemingly even after Unity Player has launched, around the loading of the first scene. Obviously, using solely this architecture, you cannot customize app launching behaviour with macOS plugins.

    A simple yet maintainable Unity iOS plugin workflow

    Now comes the step-by-step tutorial part. First we create a simple Unity iOS plugin. While you can add Objective-C sources directly to Unity, I find it more maintainable to pack plugins as libraries (or even frameworks).

    In general, I found it really convenient to have the native plugin projects (iOS, macOS, Android) in the Unity project root folder (outside Assets folder), then setup a copy command in Xcode that puts the resulting plugins into Assets folder (I actually did something very similar at Unity Android plugin tutorial (2/3) Project setup and workflow). Having this, version control also comes really easy (you can even maintain sub-projects as Git Submodules).

    So for this plugin, you should make a folder called iOS beside the Assets folder. As your native platform count grows, this folder structure can really grow with them.

    Create Cocoa Touch Static Library
    Create Override iOS Static Library

    Now in Xcode create a new Cocoa Touch Static Library called Override_iOS. It automatically creates a single class called Override_iOS. To see the plugin working, we simply log a message to the console when it is loaded. Fortunately Objective-C classes have a class method template called load you can use for this.#import “Override_iOS.h”@implementation Override_iOS+(void)load{ NSLog(@”[Override_iOS load]”); }@end See Override_iOS.m at GitHub

    New Copy Files Build Phase

    You can build the library ⌘+B, the resulting file libOverride_iOS.a can be found in the Products group. Next we make Xcode to deploy the library file automatically. First make sure you have a folder called Plugins in Unity. After that, select the Override_iOS target in Xcode, and add a New Copy Files Phase in the targets Build Phases.

    Copy Files Build Phase Absolute Path

    Drag the libOverride_iOS.a to the file list, then simply define the Assets/Plugins folder as an Absolute Path (you can see where my project resides above, you should locate yours). After hit build ⌘+B in Xcode, the library should be copied right into your Unity project.

    iOS Static Library Unity Import Setting
    iOS Unity PlayerSettings

    If you click on the libOverride_iOS.a file in Unity project window, you can see the Unity plugin inspector. Select iOS as the plugin platform, and click apply. Similar to the structure I used with plugin projects, I prefer somewhat similar folder structure for Unity builds as well. So make a folder called Build beside Assets, then create an iOS folder in it. To be able to run the app in the simulator, set Target SDK to Simulator SDK in iOS PlayerSettings.

    Unity iOS Player Plugin Static Library
    Unity iOS Player Build Phases Link Binary With Libraries

    There you go, build an iOS player, then open it in Xcode. You can see that the library has been deployed to the Libraries/Plugins group, also if you take a look on the Build Phases tab, it is linked in the Link Binary With Libraries along with the rest of the libraries.

    Load iOS Unity plugin before application launches

    Now if you run ⌘+R the resulting iOS app in the simulator, you should see the message coming from the plugin in the console indicating that the framework has been loaded. But you don’t.

    Unity iOS Build Target
    Unity iOS Build Other Linker Flags ObjC

    You don’t see it, as by default iOS won’t link the library until you need it. You can change this behaviour by tell the linker to force load static library symbols. Select the Unity-iPhone build target in Xcode, then in the Build Settings tab, add this entry to the Other Linker Flags -ObjC.

    Unity iOS Build Other Linker Flags ObjC

    After this, run ⌘+R the resulting iOS app in the simulator, and take a look at the console. You can see [Override_iOS load] right at the top, that means the plugin code has been loaded before (!) any other thing happened in the application. This is very promising considering that we want to override app launching behaviour later on.

    To manage the workflow accordingly, the build settings alteration should be included right into the Unity project. There is an API called PBXProject to manipulate the built Xcode project. So create a file in Unity called BuildPostProcessor.cs in Assets/Editor, and put this content from the Gist below (you can remove the frameworks part leaving the linker flag only). This method also proven quiet useful when hijacking macOS executables later.https://platform.twitter.com/embed/Tweet.html?dnt=false&embedId=twitter-widget-2&features=eyJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2hvcml6b25fdHdlZXRfZW1iZWRfOTU1NSI6eyJidWNrZXQiOiJodGUiLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3NwYWNlX2NhcmQiOnsiYnVja2V0Ijoib2ZmIiwidmVyc2lvbiI6bnVsbH19&frame=false&hideCard=false&hideThread=false&id=821489274692444160&lang=en&origin=http%3A%2F%2Fblog.eppz.eu%2Foverride-app-delegate-unity-ios-macos-1%2F&sessionId=4998a53c135c3b6deb85bfe528b8e68ec5bf17bd&theme=light&widgetsVersion=86e9194f%3A1641882287124&width=550px

    Implement basic features in Unity iOS plugin

    Ready to add some basic features to the plugin, actually a simple hello world that comes to Unity from the plugin side. So create some files in the plugin Xcode project. A helper tool called UnityString_C++.mm for string conversion between iOS and Unity, and a simple Objective-C++ file called Override_C++.mm with a single static external C function called getMessage.#import “UnityString_C++.mm”#import “Override_iOS.h”extern “C”{ const char* getMessage(){ return UnityStringFromNSString(@”Greetings from iOS!”); }} See Override_C++.mm at GitHub

    After this you can now create the C# counterpart in Unity. Create a C# script file called Override.cs in Assets/Plugins folder, then hook up the message from the plugin with a text label. These parts are mostly taken after the official Building Plugins for iOS documentation.using UnityEngine;using UnityEngine.UI;using System.Runtime.InteropServices;public class Override : MonoBehaviour{ public Text label;#if !UNITY_EDITOR && UNITY_IOS [DllImport(“__Internal”)] static extern string getMessage(); void Awake(){ label.text = getMessage(); }#endif} See Override.cs at GitHub

    Unity iOS Plugin Workflow Folder Structure
    Unity iOS Plugin Hello World 400px

    There you go, you have setup a really convenient plugin workflow that fits well to version control, changes, also come without any need for manual Xcode project post-processing (you can recap the resulting folder structure to the left). Ready to be filled up with all the heavy features in the upcoming parts of the series.

  • 빌드 스크립트 정리

    젠킨스를 위해서 자동 빌드 스크립트들을 정리해 두었다.

    그간 미뤘다가 대강이라도 지금 정리하는 이유는 아는 분이 부탁했기 때문이기도 한다.

    이렇게 해둔 이유는 젠킨스 사용 시에 편하게 쓰기 위함이고 .

    내가 해온 삽질이 누군가에게 의미가 있었음 좋겠다.

    시간 되면 또 다듬어봐야지 .

    이게 선처리 빌드 스크립트다.

        using UnityEditor;
        using UnityEditor.Build; // 꼭 필요함 . 
        using UnityEngine;
        class PreBuilder : IPreprocessBuild {
            public void OnPreprocessBuild (BuildTarget target, string path) 
            {
            // 빌드 전에 꼭 해야할 처리들을 해줍니다. 
            }
        
        }
    

    이게 본 빌드 스크립트다 . ( 젠킨스에서 호출하는 ).

        using System;
        using System.Collections;
        using System.Collections.Generic;
        using System.IO;
        using UnityEditor;
        using UnityEditor.Build;
        using UnityEngine;
        public class AutoBuilder : ScriptableObject {
            static string[] SCENES = FindEnabledEditorScenes ();
            static string APP_NAME = "앱 이름";
            static string TARGET_DIR;
            [MenuItem ("CI/Build For Android")]
            public static void PerformBuildAOS () {
                //  PlayerSettings.Android.bundleVersionCode = 동적으로 버전 코드 설정
                // PlayerSettings.bundleVersion = 동적으로 번들 버전 설정 
                APP_NAME = "앱이름" + PlayerSettings.Android.bundleVersionCode + ".apk";
        				// 맘대로 하셔도 별 지장 없지만, 저는 분간을 위해서 이렇게 합니다. 
                TARGET_DIR = ProjectPath + "/" + "Output";
                Directory.CreateDirectory (TARGET_DIR); // 혹시 없을까봐 디렉토리 만들어줌 
                //PlayerSettings.Android.keyaliasName = 환경 변수로 세팅하길 추천합니다. 
                //PlayerSettings.Android.keystoreName = 환경 변수로 세팅하길 추천합니다. 
                // PlayerSettings.Android.keyaliasPass = 환경 변수로 세팅하길 추천합니다. 
        	      // PlayerSettings.Android.keystorePass = 환경 변수로 세팅하길 추천합니다. 
                PlayerSettings.SetScriptingBackend (BuildTargetGroup.Android, ScriptingImplementation.IL2CPP);
                // 대략 설정해줍니다. 본인 필요에 맞춰서요 .
                PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64 | AndroidArchitecture.ARMv7;
        				// 이넘이니 여러개를 중첩할 수 있습니다. 
                EditorUserBuildSettings.androidBuildSystem = AndroidBuildSystem.Gradle; //저는 그래들로 합니다. 인터널로 하실 수도 있어요 
                EditorUserBuildSettings.androidBuildType = AndroidBuildType.Release; // 디벨롭이 필요하시면 디벨롭으로 하시면 됩니다. 
                BuildAndroid (SCENES, TARGET_DIR + "/" + APP_NAME, BuildTargetGroup.Android, BuildTarget.Android,  BuildOptions.CompressWithLz4HC | BuildOptions.Il2CPP );
                // 이것도 필요하시면 커스텀 ! 
            }
        
        
            
            private static void BuildAndroid (string[] scenes, string app_target, BuildTargetGroup build_target_group, BuildTarget build_target, BuildOptions build_options) {
        				if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android)
        		       EditorUserBuildSettings.SwitchActiveBuildTarget (BuildTargetGroup.Android, BuildTarget.Android);
                //현 세팅이 안드로이드 아니면 안드로이드로 바꿔줍니다. 만약 다시 원상태로 돌아오고 싶으면 PostBuild 활용하세요
                BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions ();
                buildPlayerOptions.scenes = scenes;
                buildPlayerOptions.locationPathName = app_target;
                buildPlayerOptions.target = BuildTarget.Android;
                buildPlayerOptions.options = build_options;
                var report = BuildPipeline.BuildPlayer (buildPlayerOptions);
            }
        
        		private static string[] FindEnabledEditorScenes () 
        		{
                List<string> EditorScenes = new List<string> ();
        
                foreach (EditorBuildSettingsScene scene in EditorBuildSettings.scenes) {
                    if (!scene.enabled) continue;
                    EditorScenes.Add (scene.path);
                }
        
                return EditorScenes.ToArray ();
            }
        
            static string ProjectPath {
                get { return Application.dataPath.Substring (0, Application.dataPath.LastIndexOf ('/')); }
            } // 귀찮고 시간 없어서 대강 만들어서 쓰고 있는 중인 함수 
        
            [MenuItem ("CI/Build iOS")]
            public static void PerformBuildIOS () {
                BuildOptions opt = BuildOptions.Il2CPP; // 기본이 cpp
                PlayerSettings.iOS.sdkVersion = iOSSdkVersion.DeviceSDK; // 시뮬레이터에서 돌리시려면 시뮬레이터 sdk 로 
                //PlayerSettings.bundleVersion = GetArg ("-BUNDLE_VERSION"); //todo
                //PlayerSettings.iOS.buildNumber = (GetArg ("-VERSION_CODE")); //todo
                char sep = Path.DirectorySeparatorChar;
                string BUILD_TARGET_PATH = ProjectPath + "/ios"; //ios 폴더로 뱉습니다. 
                Directory.CreateDirectory (BUILD_TARGET_PATH);
                PlayerSettings.SetScriptingBackend (BuildTargetGroup.iOS, ScriptingImplementation.IL2CPP);
                try {
                    BuildIOS (SCENES, BUILD_TARGET_PATH, BuildTarget.iOS, opt);
                } catch (System.Exception e) {
                    Debug.Log (e.Message);
                }
            }
        
            static void BuildIOS (string[] scenes, string target_path,
                BuildTarget build_target, BuildOptions build_options) {
                EditorUserBuildSettings.SwitchActiveBuildTarget (BuildTargetGroup.iOS, build_target);
                string res = BuildPipeline.BuildPlayer (scenes, target_path, build_target, build_options);
                if (res.Length > 0) { throw new Exception ("BuildPlayer failure: " + res); }
            }
        
        }
    

    이게 후처리 빌드 스크립트이다.

        #if UNITY_IOS
        using System.IO;
        using UnityEditor;
        using UnityEditor.Build;
        using UnityEditor.iOS.Xcode;
        using UnityEngine;
        // ios 유니티 빌드는 먼저 엑스코드용으로 빼준다음에 다시 시작되니. 빌드후 이렇게 후처리 해주고 엑스코드 에서 아카이빙 해주면 끝이납니다. 이부분은 젠킨스 파트에서 더 설명할게요 . 
        class PostBuilder : IPostprocessBuild
        {
            public int callbackOrder { get { return 0; } }
        
            static string ProjectPath
            {
                get { return Application.dataPath.Substring(0, Application.dataPath.LastIndexOf('/')); }
            }
            public void OnPostprocessBuild(BuildTarget buildTarget, string pathToBuiltProject)
            {
                // Stop processing if targe is NOT iOS 
                if (buildTarget != BuildTarget.iOS) return;
                // Initialize PbxProject 
                var projectPath = pathToBuiltProject + "/Unity-iPhone.xcodeproj/project.pbxproj";
                PBXProject pbxProject = new PBXProject();
                pbxProject.ReadFromFile(projectPath);
                string targetGuid = pbxProject.TargetGuidByName("Unity-iPhone");
                pbxProject.AddCapability(targetGuid, PBXCapabilityType.InAppPurchase);
                pbxProject.AddCapability(targetGuid, PBXCapabilityType.iCloud);
                pbxProject.AddCapability(targetGuid, PBXCapabilityType.GameCenter);
                pbxProject.AddCapability(targetGuid, PBXCapabilityType.PushNotifications);
        //이런 식으로 권한 추가해주면 되는데, 특이하게 푸시나 아이클라우드는 추가로 더 해줘야합니다.  (다음에 나옴 ) 
        
                pbxProject.SetBuildProperty(targetGuid, "DEVELOPMENT_TEAM", "애플 개발자 팀 아이디 ");
                var guid = pbxProject.FindFileGuidByProjectPath("Classes/UI/Keyboard.mm");
                var flags = pbxProject.GetCompileFlagsForFile(targetGuid, guid);
                flags.Add("-fno-objc-arc");
                pbxProject.SetCompileFlagsForFile(targetGuid, guid, flags);
                pbxProject.AddFrameworkToProject(targetGuid, "CloudKit.framework", false);
        
                // Apply settings 
                File.WriteAllText(projectPath, pbxProject.WriteToString());
                // Samlpe of editing Info.plist 
                var plistPath = Path.Combine(pathToBuiltProject, "Info.plist");
                var plist = new PlistDocument();
                plist.ReadFromFile(plistPath);
                // Add string setting 
                plist.root.SetBoolean("ITSAppUsesNonExemptEncryption", false); // 앱이 암호화를 쓰는지 제출하는 건데, 안하면 귀찮게 바로 테스트플라이트가 안올라가고 의미없는 설문을 더 해야하니 . 암호화를 특별나게 쓰고 있지만 않다면 이렇게 합시다. 
                // Add URL Scheme\
                // Apply editing settings to Info.plist
                var cap = plist.root.CreateArray("UIRequiredDeviceCapabilities");
                cap.AddString("gamekit");
                plist.WriteToFile(plistPath);
        
        
                //-----
        
                var file_name = "unity.entitlements"; // 이거없으면 푸시 안됩니다 
                var proj_path = projectPath;
                var proj = new PBXProject();
                proj.ReadFromFile(proj_path);
        
        
                // target_name = "Unity-iPhone"
                var target_name = PBXProject.GetUnityTargetName();
                var target_guid = proj.TargetGuidByName(target_name);
                var dst = pathToBuiltProject + "/" + target_name + "/" + file_name;
                try
                {
                    File.WriteAllText(dst, entitlements);
                    proj.AddFile(target_name + "/" + file_name, file_name);
                    proj.AddBuildProperty(target_guid, "CODE_SIGN_ENTITLEMENTS", target_name + "/" + file_name);
                    proj.WriteToFile(proj_path);
                }
                catch (IOException e)
                {
                    Debug.Log("Could not copy entitlements. Probably already exists. " + e);
                }
        
                UnityEditor.iOS.Xcode.ProjectCapabilityManager pcm = new UnityEditor.iOS.Xcode.ProjectCapabilityManager(proj_path, dst, target_name);
                pcm.AddPushNotifications(false);
        
            }
        // 슬픈 하드코딩 타자가 길어서 슬픈 짐승이여 
        
            private const string entitlements = @"
             <?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>aps-environment</key>
                     <string>production</string>
                 </dict>
             </plist>";
        
        }
        #endif
  • [Unity] 유니티 안드로이드 Gradle 빌드 오류 상세보기

    원인

    유니티로 And/iOS 네이티브 기능 및 광고SDK를 넣다 보면 심심치않게 빌드 오류를 자주 보게 됩니다.
    특히 요즘 유니티 에디터에서도 Gradle빌드로 바뀌는 추세라 점점 더 이런 화면을 접합니다.

    Gradle_Error

    대부분은 유니티 콘솔창에 보이지만 어쩔땐 콘솔에도 자세하게 적혀있지 않는 경우가 있습니다.
    아래 처럼 나오면 정말 노답이죠..

    FAILURE: Build failed with an exception.
    
    * What went wrong:
    Execution failed for task ':transformClassesWithMultidexlistForRelease'.
    > com.android.build.api.transform.TransformException: Error while generating the main dex list.
    
    * Try:
    Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

    해결

    제가 생각해본 방법은 Android Studio를 이용하여 Gradle 빌드를 해보는 방법 입니다.
    먼저 안드로이드 빌드를 하고, Temp폴더의 gradleOut 폴더가 있는지 아래와 같이 존재하는지 확인해 봅니다.

    GradleOutFolder

    폴더가 존재한다면 AndroidStudio를 키고 위의 경로를 추가해 줍니다.

    AndStudio_Intro

    프로젝트를 열면 바로 위에 아래와 같은 창이 위에 뜨는데 Ok를 눌러 줍니다.

    3_GradleSync_ok

    Ok를 누르면 아래와 같이 열심히 돌아가다가 에러 난 부분을 좀 더 명확히 확인하실 수 있습니다.

    5_AndStudio_Error