iOS使用Unity容器动态加载3D模型

项目背景
我们的APP是一个数字藏品平台,里面的很多藏品需要展示3D模型,3D模型里面可能会包含场景,动画,交互。而对应3D场景来说,考虑到要同时支持iOS端,安卓端,Unity是个天然的优秀方案。
对于Unity容器来说,需要满足如下的功能:
1.在APP启动时,需要满足动态下载最新的模型文件。
2.在点击藏品查看模型时,需要根据不同的参数展示不同的模型,并且在页面消失后,自动卸载对应的模型。
如果要实现上面说的功能则是需要使用Unity的打包功能,将资源打包成AssetBundle资源包,然后把ab包进行上传到后台服务器,然后在APP启动时从服务器动态下载,然后解压到指定的目录中。
当用户点击藏品进入到Unity容器展示3D模型时,则可以根据传递的模型名称和ab包名,从本地的解压目录中加载对应的3D模型。
AssetBundle打包流程
创建AB打包脚本
AB包打包是在Editer阶段里。
首先要创建一个Editer目录并把脚本放置到这个目录下面,注意它们的层级关系:Assert/Editor/CS脚本,这个层级关系是固定的,不然会报错。

脚本实现如下:

复制代码
using UnityEditor;
using System.IO;


/// <summary>
///
/// </summary>

public class AssetBundleEditor 
{
    //1.编译阶段插件声明
    [MenuItem("Assets/Build AssetBundles")]
    static void BuildAssetBundles() {
        string dir = "AssetBundles";
        if (!Directory.Exists(dir)) {
            //2.在工程根目录下创建dir目录
            Directory.CreateDirectory(dir);
        }
        //3.构建AssetBundle资源,AB资源包是一个压缩文件,可以把它看成是一个压缩的文件夹,里面
        //可能包含多个文件,预制件,材质,贴图,声音。
        BuildPipeline.BuildAssetBundles(dir, BuildAssetBundleOptions.None, BuildTarget.iOS);
    }
}

设置需要打包的资源
可以在Project选中一个资源(预制件,材质,贴图,声音等),然后在Inspector下面的AssetBundle设置打包成的名称和后缀。如果名称带层级的如:scene/cube,那么打出来的AB包会自己添加一个scene目录,然后在目录下存在了cube资源包。
AB包可以存在依赖关系,比如GameObjectA和GameObjectB共同使用了Material3, 然后它们对应的AssetBundle名称和后缀分别为cube.ab, capsule.ab, share.ab。
虽然GameObjectA中包含了Material3资源,但是 AssetBundle在打包时如果发现Material3已经被打包成了share.ab, 那么就会只打GameObjectA,并在里面设置依赖关系就可以了。

使用插件工具进行打包
1.从gitHub上下载源码,然后将代码库中的Editor目录下的文件复制一份,放到工程Target的Assets/Editor目录下。打开的方式是通过点击Window->AssetBundle Browser进行打开
插件工具地址:https://github.com/Unity-Technologies/AssetBundles-Browser

2.打包时,可以选择将打出的ab包内置到项目中,勾选Copy StreamingAssets ,让打出的内容放置在StreamingAssets目录下,这样可以将ab资源内置到Unity项目中。
3.通过上面的操作会完成资源打包,然后将打包的产物压缩上传到后台。

AssetsBundle资源包的使用
APP启动时,下载AssetBundle压缩包, 然后解压放置在沙盒Documents/AssetsBundle目录下,当点击APP中的按钮进入到Unity容器页面时,通过包名加载对应的ab包进行Unity页面展示。

复制代码
   /// <summary>
    ///读取原生沙盒Documents/AssetsBundle目录下的文件,Documents/AssetsBundle下的文件通过Native原生下载的资源
    /// </summary>
    /// <param name="abName">Documents/AssetsBundle下的ab文件</param>
    /// <returns>读取到的字符串</returns>
    public static AssetBundle GetNativeAssetFromDocumentsOnProDownLoad(string abName)
    {
        string localPath = "";
        if (Application.platform == RuntimePlatform.Android)
        {
            localPath = "jar:file://" + Application.persistentDataPath + "/AssetsBundle/" + abName;
        }
        else
        {
            localPath = "file://" + Application.persistentDataPath + "/AssetsBundle/" + abName;
        }
        UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(localPath);
        var operation = request.SendWebRequest();
        while (!operation.isDone)
        { }
        if (request.result == UnityWebRequest.Result.ConnectionError)
        {
            Debug.Log(request.error);
            return null;
        }
        else
        {
            AssetBundle assetBundle = DownloadHandlerAssetBundle.GetContent(request);
            return assetBundle;
        }
        //UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(localPath);
        //yield return request.Send();
        //AssetBundle assetBundle = DownloadHandlerAssetBundle.GetContent(request);
        //return assetBundle;

    }

注意:当离开Unity容器时需要卸载里面加载的ab包

复制代码
   public void TestUnLoadGameObject()
    {
        UnLoadGameObjectWithTag("NFT");
    }

    public void UnLoadGameObjectWithTag(string tagName)
    {
        GameObject go = GameObject.FindWithTag(tagName);
        if (go) {
            Destroy(go, 0.5f);
        } else
        {
            Debug.Log(go);
        }
        
    }

    public void UnLoadAllGameObjectWithTag(string tagName)
    {
        GameObject[] gos = GameObject.FindGameObjectsWithTag(tagName);
        foreach (GameObject go in gos) {
            Destroy(go, 0.5f);
        }

    }

模型的相关设置
手势支持
对于加载完成后的模型需要添加手势支持,允许用户旋转,缩放查看,不能说只能静止观看。这里添加手势控制脚本用于支持手势功能。

模型实现成功后,把实例对象设置到GestureController组件的Target上面,实现模型的手势支持。
加载Unity内置ab资源包的脚本实现:

复制代码
   public void TestLoadStreamingAssetBundle() {
        LoadStreamingAssetBundleWithABName("cube.ab", "Cube", "NFT");
    }

    public void LoadStreamingAssetBundleWithABName(string abName, string gameObjectName, string tagName)
    {

        AssetBundle ab = FileUtility.GetNativeAssetFromStreamingAssets(abName);
        GameObject profab = ab.LoadAsset<GameObject>(gameObjectName);
        profab.tag = tagName;
        Instantiate(profab);


        GestureController gc = GameObject.FindObjectOfType<GestureController>();
        gc.target = profab.transform;

        ab.Unload(false);
    }

Unity场景切换的脚本实现:

复制代码
    //接收原生事件:切换场景
    public void SwitchScene(string parmas)
    {
        Debug.Log(parmas);
        Param param = new Param();
        Param res = JsonDataContractJsonSerializer.JsonToObject(parmas, param) as Param;
        Debug.Log(res.name);

        Debug.Log("------------");
        for (int i = 0; i < SceneManager.sceneCount; i++) {
            Scene scene = SceneManager.GetSceneAt(i);
            Debug.Log(scene.name);
        }

        SceneManager.LoadScene(res.name, LoadSceneMode.Single);

        Debug.Log("------------");
        for (int i = 0; i < SceneManager.sceneCount; i++)
        {
            Scene scene = SceneManager.GetSceneAt(i);
            Debug.Log(scene.name);
        }
    }

Unity导出iOS项目
构建UnityFramework动态库


此时将得到一个iOS 工程。
原生与Unity通信
创建原生与Unity通信接口,并放置到Unity项目中。

NativeCallProxy.h文件创建通信协议

复制代码
#import <Foundation/Foundation.h>

@protocol NativeCallsProtocol

@required

/// Unity调用原生
/// - Parameter params: {"FeatureName":"下载资源", "params": "参数"}
- (void)callNative:(NSString *)params;
@end

__attribute__ ((visibility("default")))


@interface NativeCallProxy : NSObject
// call it any time after UnityFrameworkLoad to set object implementing NativeCallsProtocol methods
+ (void)registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi;
@end

NativeCallProxy.mm文件实现如下:

复制代码
#import "NativeCallProxy.h"

@implementation NativeCallProxy
id<NativeCallsProtocol> api = NULL;
+ (void)registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi
{
    api = aApi;
}

@end


extern "C" {
void callNative(const char * value);
}


void callNative(const char * value){
    return [api callNative:[NSString stringWithUTF8String:value]];
}

原生的Delegate的实现

复制代码
#pragma mark - NativeCallsProtocol
- (void)callNative:(NSString *)params {
    NSLog(@"收到Unity的调用:%@",params);
}

Unity调用原生

复制代码
   //重要声明,声明在iOS原生中存在下面的方法,然后C#中可以直接进行调用
    [DllImport("__Internal")]
    static extern void callNative(string value);


    public void changeLabel(string textString) {
        tmpText.text = textString;
    }

    public void btnClick() {
        Debug.Log(tmpInput.text);
        callNative(tmpInput.text);
    }

然后根据工程设置,生成UnityFramework。创建UnityFramework的详细流程可以参考文章:https://www.cnblogs.com/zhou--fei/p/17622488.html
然后其他需要拥有Unity能力的APP就可以集成此动态库,展示Unity视图。
原生与Unity通信交互
首先定义一套接口,用于规定原生到Unity发送消息时,参数对应的意义。

然后在场景中添加DispatchGO游戏对象,在此对象上面添加DispatchGO组件,DispatchGO组件用于接收原生发送过来的消息,并进行逻辑处理。

复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;


public class Param {
    public string packageName { get; set; }
    public string name { get; set; }
    public string tag { get; set; }
    public string type { get; set; }
    public string isAll { get; set; }
}

public class DispatchGO : MonoBehaviour
{

    //接收原生事件
    public void DispatchEvent(string parmas) {
        Debug.Log(parmas);
        //事件分发

        ChangeLabel cl = GameObject.FindObjectOfType<ChangeLabel>();
        cl.changeLabel(parmas);
    }

    //接收原生事件:加载模型
    public void LoadModel(string parmas)
    {
        Debug.Log(parmas);
        Param param = new Param();
        Param res = JsonDataContractJsonSerializer.JsonToObject(parmas, param) as Param;
        Debug.Log(res.packageName);
        Debug.Log(res.name);
        Debug.Log(res.tag);
        Debug.Log(res.type);

        if (res.type == "0")
        {
            LoadAssetUtility laUnity = GameObject.FindObjectOfType<LoadAssetUtility>();
            laUnity.LoadStreamingAssetBundleWithABName(res.packageName, res.name, res.tag);
        }
        else {
            LoadAssetUtility laUnity = GameObject.FindObjectOfType<LoadAssetUtility>();
            laUnity.LoadNativeAssetBundleWithABName(res.packageName, res.name, res.tag);
        }
    }

    //接收原生事件:卸载模型
    public void UnLoadModel(string parmas)
    {
        Debug.Log(parmas);
        Param param = new Param();
        Param res = JsonDataContractJsonSerializer.JsonToObject(parmas, param) as Param;

        UnLoadAssetUtility unLAUnity = GameObject.FindObjectOfType<UnLoadAssetUtility>();
        if (res.isAll == "1")
        {
            unLAUnity.UnLoadAllGameObjectWithTag(res.tag);
        }
        else {
            unLAUnity.UnLoadGameObjectWithTag(res.tag);
        }
    }

    //接收原生事件:切换场景
    public void SwitchScene(string parmas)
    {
        Debug.Log(parmas);
        Param param = new Param();
        Param res = JsonDataContractJsonSerializer.JsonToObject(parmas, param) as Param;
        Debug.Log(res.name);

        Debug.Log("------------");
        for (int i = 0; i < SceneManager.sceneCount; i++) {
            Scene scene = SceneManager.GetSceneAt(i);
            Debug.Log(scene.name);
        }

        SceneManager.LoadScene(res.name, LoadSceneMode.Single);

        Debug.Log("------------");
        for (int i = 0; i < SceneManager.sceneCount; i++)
        {
            Scene scene = SceneManager.GetSceneAt(i);
            Debug.Log(scene.name);
        }
    }

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

在iOS原生侧,本地通过使用unityFramework的sendMessageToGOWithName方法从原生想Unity发送消息。

复制代码
        case 103:
        {
            NSDictionary *params = @{
                @"tag":@"NFT",
                @"isAll":@"1"
            };
            [ad.unityFramework sendMessageToGOWithName:"DispatchGO" functionName:"UnLoadModel" message:[self serialJsonToStr:params]];
        }
            break;
        case 104:
        {
            NSDictionary *params = @{
                @"name":@"DemoScene"
            };
            [ad.unityFramework sendMessageToGOWithName:"DispatchGO" functionName:"SwitchScene" message:[self serialJsonToStr:params]];
        }
            break;

Unity通过调用iOS中协议声明的方法void callNative(string value); 进行调用。

复制代码
    //重要声明,声明在iOS原生中存在下面的方法,然后C#中可以直接进行调用
    [DllImport("__Internal")]
    static extern void callNative(string value);

    public void btnClick() {
        Debug.Log(tmpInput.text);
        callNative(tmpInput.text);
    }

原生端创建Unity容器
在APP启动时,对UnityFramework进行初始化。

复制代码
@implementation AppDelegate


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    [UnitySceneManager sharedInstance].launchOptions = launchOptions;
    [[UnitySceneManager sharedInstance] Init];
    return YES;
}

UnitySceneManager的主要实现逻辑如下:#import "UnitySceneManager.h"#import <UnityFramework/NativeCallProxy.h>

复制代码
extern int argcApp;
extern char ** argvApp;

@interface UnitySceneManager()<UnityFrameworkListener, NativeCallsProtocol>

@end

@implementation UnitySceneManager
#pragma mark - Life Cycle
+ (instancetype)sharedInstance {
    static UnitySceneManager *shareObj;
    static dispatch_once_t onceKey;
    dispatch_once(&onceKey, ^{
        shareObj = [[super allocWithZone:nil] init];
    });
    return shareObj;
}

+ (instancetype)allocWithZone:(struct _NSZone *)zone {
    return [self sharedInstance];
}

- (instancetype)copyWithZone:(struct _NSZone *)zone {
    return self;
}

#pragma mark - Private Method
- (void)Init {
    [self initUnityFramework];
    [NativeCallProxy registerAPIforNativeCalls:self];
}

- (void)unloadUnityInternal {
    if (self.unityFramework) {
        [self.unityFramework unregisterFrameworkListener:self];
    }
    self.unityFramework = nil;
}

- (BOOL)unityIsInitialized {
    return (self.unityFramework && self.unityFramework.appController);
}
// MARK: overwrite

#pragma mark - Public Method
- (void)initUnityFramework {
    UnityFramework *unityFramework = [self getUnityFramework];
    self.unityFramework = unityFramework;
    [unityFramework setDataBundleId:"com.zhfei.framework"];
    [unityFramework registerFrameworkListener:self];
    [unityFramework runEmbeddedWithArgc:argcApp argv:argvApp appLaunchOpts:self.launchOptions];
}

- (UnityFramework *)getUnityFramework {
    NSString* bundlePath = nil;
    bundlePath = [[NSBundle mainBundle] bundlePath];
    bundlePath = [bundlePath stringByAppendingString: @"/Frameworks/UnityFramework.framework"];

    NSBundle* bundle = [NSBundle bundleWithPath: bundlePath];
    if ([bundle isLoaded] == false) [bundle load];

    UnityFramework* ufw = [bundle.principalClass getInstance];
    if (![ufw appController])
    {
        // unity is not initialized
        [ufw setExecuteHeader: &_mh_execute_header];
    }
    return ufw;
}

#pragma mark - Event

#pragma mark - Delegate
#pragma mark - UnityFrameworkListener
- (void)unityDidUnload:(NSNotification*)notification {
    
}

- (void)unityDidQuit:(NSNotification*)notification {
    
}

#pragma mark - NativeCallsProtocol
- (void)callNative:(NSString *)params {
    NSLog(@"收到Unity的调用:%@",params);
}

#pragma mark - Getter, Setter

#pragma mark - NSCopying

#pragma mark - NSObject

#pragma mark - AppDelegate生命周期绑定
- (void)applicationWillResignActive {
    [[self.unityFramework appController] applicationWillResignActive: [UIApplication sharedApplication]];
}

- (void)applicationDidEnterBackground {
    [[self.unityFramework appController] applicationDidEnterBackground: [UIApplication sharedApplication]];
}

- (void)applicationWillEnterForeground {
    [[self.unityFramework appController] applicationWillEnterForeground: [UIApplication sharedApplication]];
}

- (void)applicationDidBecomeActive {
    [[self.unityFramework appController] applicationDidBecomeActive: [UIApplication sharedApplication]];
}

- (void)applicationWillTerminate {
    [[self.unityFramework appController] applicationWillTerminate: [UIApplication sharedApplication]];
}


@end

Unity容器的原生实现,其实也是在一个普通的ViewController里面包含了Unity视图的View。

复制代码
#import "UnityContainerViewController.h"
#import "UnitySceneManager.h"

@interface UnityContainerViewController ()

@end

@implementation UnityContainerViewController
#pragma mark - Life Cycle
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    [self setupUI];
}

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];
    UnitySceneManager *ad = [UnitySceneManager sharedInstance];
    ad.unityFramework.appController.rootView.frame = self.view.bounds;
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    UnitySceneManager *ad = [UnitySceneManager sharedInstance];
    [ad.unityFramework pause:NO];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    UnitySceneManager *ad = [UnitySceneManager sharedInstance];
    [ad.unityFramework pause:YES];
}


#pragma mark - Private Method
- (void)setupUI {
    self.view.backgroundColor = [UIColor whiteColor];
    UnitySceneManager *ad = [UnitySceneManager sharedInstance];
    
    UIView *rootView = ad.unityFramework.appController.rootView;
    rootView.frame = [UIScreen mainScreen].bounds;
    [self.view addSubview:rootView];
    [self.view sendSubviewToBack:rootView];
}