找回密码
 立即注册
查看: 298|回复: 2

Unity3d Native开发那些事

[复制链接]
发表于 2022-10-2 19:42 | 显示全部楼层 |阅读模式
前言

在Unity3d引擎中,我们通过编写C#脚本就可以实现各种各样的游戏功能并发布到不同的游戏平台。这是因为Unity3d引擎在设计时屏蔽了不同平台(常见平台诸如iOS/Android/Windows等)的差异,提供统一的接口供开发者调用,大大降低了开发的门槛,提升了跨平台开发的效率。
虽然Unity3d引擎已经为开发游戏提供了极大地便利,将开发者从繁琐的平台细节中解脱了出来,但是在一些特定的情况下,我们仍然不可避免地需要直接和平台底层软硬件资源打交道,这个过程就涉及到了Unity3d Native开发。Native在本文的语义为原生的或者本机相关的,也即特定平台所支持的一些特性,包括操作系统、编程语言、传感器等软硬件功能。
基于Unity3d的Native开发,本文主要指借助Unity3d提供的原生插件(Native Plug-in)机制,和特定的平台进行程序代码上的交互。那么我们在什么情况下会需要用到Native开发呢?这里从四个方面进行了概括,它们分别是:
首先,复用已有的Native功能。主要包括一些平台相关的现有解决方案,如基于C/C++实现的源码或编译的第三方插件,各种渠道SDK等。
其次,自行开发新的Native功能。如出于性能或安全考虑将代码封装到Native层面、涉及Native特性的公共SDK开发、当前Unity3d不支持但又必须调用平台原生接口等。
再次,修复/避开Unity3d bug。众所周知,每个版本的Unity3d都会在特定平台或者平台特定系统版本上存在bug(Unity自身的原因或者平台/系统本身存在漏洞)。要解决这些问题,也可能需要在Native层面寻求解决方案。
最后,应用商店审核及监管合规。Appstore、Google Play每年都会更新适配指南,国内应用商店也不时涌现各种审核需求。与此同时监管部门对个人隐私等问题越来越关注,不断对APP提出各种合规要求。在技术上解决这些问题也会涉及到Native开发相关的内容。
接下来本文将详细介绍一下在Unity3d引擎下进行Native功能开发的一些要点,并结合实例进行讲解。
原生插件机制

在Unity3d下做 Native开发依赖于它的Plugin-in机制,即原生插件机制。原生插件是指一些使用C、C++、Java、Objective-C等语言编写或编译的与平台相关的库文件(对应英文Libraries),这里面Java语言虽然本身可以支持跨平台,但是在Unity3d下一般只会应用于针对Android平台的开发中,所以也把它归类到原生插件的开发。Unity3d通过插件的方式,允许开发者从C#端调用这些原生插件。引入插件的基本处理流程包括以下三个步骤:
首先,针对特定平台开发新的插件或者导入原有的插件,这些插件需要暴露接口给C#端调用,接口须满足C语言的调用规约(C-language calling convention),Android端的Java插件的调用情况比较特殊,不需要遵循这个规则,具体在后文会举例说明。
其次,将上述插件以源码或编译库的形式放到Unity3d中(Unity3d比较新的版本都可以支持源码形式的导入),并指定好生效的平台。
最后,Unity3d端在C#中声明插件暴露的接口,并像调用普通方法一样调用所声明的接口,从而实现对于Native功能的访问。
上述处理过程中,很关键的内容就是跨语言交互,以iOS/Android/Windows平台为例,下图描述了在处理这些平台问题时,比较常涉及到的各种跨语言调用情形:



Unity3d在iOS/Android/Windows平台上的跨语言调用示意图

从上图可以看出,三个平台中C#与C/C++之间的相互调用关系大同小异,Android平台相较其他平台多了C#与Java以及Java与C/C++(Kotlin在交互行为上和Java类似,这里不做介绍)的相互调用,iOS平台的Objective-C特性不会出现在最终调用接口上,在理解跨语言调用方面可以跳过其实现细节。那么在Native开发中最常可能涉及的调用关系总结起来可以有三大类共计6条调用路径,分别为C#与C/C++交互、C#与Java交互、Java与C/C++交互。下面我们结合代码来介绍一下这6条调用路径的具体内容。
C#与C/C++交互

C#调用C/C++
首先我们来看一下C#如何调用C/C++代码。下面这段C/C++代码片段包括了一个getMessage方法,该方法返回一个“hello cpp”的字符串。
//hellocpp.cpp
#if defined(__cplusplus) //C++编译器都预定义了宏 __cplusplus
extern "C"{ //声明链接规范,C++编译器允许在声明中带 extern "C" ,表示按照C的方式链接
#endif
const char * getMessage()
{
    const char * msg = "hello cpp";
    char * val = (char *)malloc(strlen(msg) + 1);
    strcpy(val, msg);
    return val;//直接返回msg会崩溃
}
#if defined(__cplusplus)
}
#endif上述代码可以以源码的形式(需要使用IL2CPP构建项目)或编译为平台特定二进制文件(如iOS平台编译为.a,Android平台编译为.so,Windows平台编译为.dll,具体的编译方法本文不做进一步展开)后导入到Unity3d。下面的代码演示了如何从C#调用上述getMessage接口:
using UnityEngine;
using System.Runtime.InteropServices;
public class UnityNativeExample
{
#if UNITY_IOS && !UNITY_EDITOR
    //iOS C接口的插件会被静态链接到可执行文件,lib名称固定为"__Internal"
    [DllImport("__Internal", EntryPoint = "getMessage")]
#elif UNITY_ANDROID && !UNITY_EDITOR
    //Android C接口的插件动态加载,lib文件名称规则为libxxx.so,这里名称填写xxx部分即可
    [DllImport("hellocpp", EntryPoint = "getMessage")]
#else
    //Windows C接口的插件动态加载,lib文件名称规则为xxx.dll,这里名称填写xxx部分即可
    //x86通过P/Invoke调用方法时,默认调用规约是StdCall,Unity3d插件调用规约为Cdecl,因此需要明确指定
    [DllImport("hellocpp", EntryPoint = "getMessage", CallingConvention = CallingConvention.Cdecl)]
#endif
    extern static string getMessage();
    public static void DoPrintMessage()
    {
        Debug.Log(getMessage());
    }
}前面说到Unity3d只支持调用C风格的接口,如果Native代码是用C++来实现,暴露的接口必须满足C语言的调用规约,对外接口需要额外包一层extern “C”。C#端声明的Native方法必须标记为extern和static,在DllImport部分,EntryPoint是可选的,当声明的方法与Native方法名一致时可以不加。这里的Native代码还演示了一种特殊的情况,即当方法返回值为字符串时,需要为返回值额外分配内存,传回到C#端时Unity3d会负责处理好内存释放的问题,不会造成内存泄漏。
C/C++调用C#
接下来我们看一下C/C++代码如何回调C#。这里分两种情况:在iOS端,Unity3d提供了UnitySendMessage方法,该方法可以将字符串类型的消息从Native层发送至C#层。如下所示
#import "UnityInterface.h"
UnitySendMessage("GameObjectName", "MethodName", "Message");该方法有三个参数,第一个参数为Unity3d接收消息的Game Object名,第二个参数为C#脚本中被调用的方法名,第三个参数为方法附带的字符串型消息。通过这种方式回调C#使用简单,但也存在一些限制, 比如Game Object名称需要保证全局唯一,否则会导致响应冲突,C#端的方法签名必须满足void MethodName(string message)的格式,同时该方法的执行会比发出消息时机延迟一帧(这个延迟保证了调用代码执行在Unity3d的主线程)。
在Android端,Unity3d提供了Java版本的UnitySendMessage方法来处理回调C#,这在后文会详细介绍。至于在C/C++层面,iOS/Android/ Windows端Unity3d可以利用Delegate机制来实现回调C#。下面我们在C/C++下模拟实现一个SendMessage功能
//hellocpp.cpp
#if defined(__cplusplus)
extern "C"{
#endif
typedef void(*UnitySendMessage)(const char* msg);
void setCustomSendMsgCallback(UnitySendMessage sendMsgCallback)
{
    if(sendMsgCallback!=NULL)
    {
        const char * msg = "hello cpp";
        sendMsgCallback(msg);//利用函数指针传递消息
    }
}
#if defined(__cplusplus)
}
#endif对应的C#端调用示例如下
using UnityEngine;
using System.Runtime.InteropServices;
public class UnityNativeExample
{
    //与Native层面函数指针相对应的C# Delegate
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    public delegate void UnitySendMessageDelegate(string msg);

#if UNITY_IOS && !UNITY_EDITOR
    [DllImport("__Internal")]
#else //Android & Windows
    [DllImport("hellocpp", CallingConvention = CallingConvention.Cdecl)]
#endif
    extern static void setCustomSendMsgCallback(UnitySendMessageDelegate sendMsgCallback);

    //Native回调必须标记MonoPInvokeCallback(使用IL2CPP构建项目时)并且必须为静态方法
    [AOT.MonoPInvokeCallback(typeof(UnitySendMessageDelegate))]
    private static void SimulateUnitySendMessage(string msg)
    {  //Loom的作用是将回调方法的具体执行延迟到主线程
        Loom.QueueOnMainThread(() =>{ Debug.Log(msg);});
    }
    //C#调用此方法,从C++下传回消息并利用Debug.Log打印输出到Unity3d控制台
    public static void DoSendMessage()
    {
        setCustomSendMsgCallback(SimulateUnitySendMessage);
    }
}上述代码演示了利用Delegate机制来实现从Native层给C#层发消息,这种方式实现比较复杂,对比UnitySendMessage,它可以传递复杂类型的数据。同时可以看出Delegate可以实现同步的回调而无需延迟一帧,不过在实际使用中,仍可能需要将回调的方法放到Unity3d的主线程去执行,上述代码中的Loom类就是这个作用,我们将在后文对其工作原理进行介绍。另外注意观察上述代码中message以函数参数的形式返回时,相对于之前的以函数返回值形式返回,不需要再动态开辟内存。
C#与Java的交互

C#调用Java
在Unity3d中,C#与Java代码的交互主要用于Android平台,我们先来看以下在C#下调用Java的一些技术方案。Unity3d提供了从低级到高级两种API来处理C#调用Java,本质上是利用Android的JNI(Java Native Interface)在C++层面与Java进行通信。其中低级的API由AndroidJNI及AndroidJNIHelper提供,通过这种方式调用Java代码需要对Android JNI技术有比较深入的了解,开发门槛比较高;高级的API由AndroidJavaClass和AndroidJavaObject提供,它们基于AndroidJNI及AndroidJNIHelper实现,自动处理了很多JNI的调用细节。我们先来看一段Java代码片段:
package com.netease.leihuo.demo;
public class HelloJava {
    public static int max(int a, int b) {
        return (a>b) ? a : b;
    }
}上述代码可以以源码的形式或编译为.jar/.aar后导入到Unity3d。下面的代码演示了如何从C#调用上述max接口。首先是利用AndroidJNI/AndroidJNIHelper调用示例如下:
IntPtr jc = AndroidJNI.FindClass("com/netease/leihuo/demo/HelloJava");
IntPtr methodId = AndroidJNI.GetStaticMethodID(jc, "max", "(II)I");
jvalue jv_a = new jvalue();jv_a.i = a;
jvalue jv_b = new jvalue();jv_b.i = b;
int maxValue = AndroidJNI.CallStaticIntMethod(jc, methodId, new jvalue[] { jv_a, jv_b });
AndroidJNI.DeleteLocalRef(jc);利用AndroidJavaClass/AndroidJavaObject调用示例如下:
using (AndroidJavaClass jc = new AndroidJavaClass("com.netease.leihuo.demo.HelloJava"))
{
    maxValue = jc.CallStatic<int>("max", a, b);
}从以上代码我们可以看,AndroidJavaClass/AndroidJavaObject相比于AndroidJNI/AndroidJNIHelper调用相同的功能时使用更简便,在绝大多数情况下都推荐使用前者。需要注意的是这种涉及跨语言的调用开销相对比较大,实际应用中要避免频繁调用。
Java调用C#
Unity3d提供了两个方案来处理Java回调C#的问题。其一为UnitySendMessage接口,同前文提到的iOS端UnitySendMessage方法类似,这里不做详细介绍,示例如下:
import com.unity3d.player.UnityPlayer;
UnityPlayer.UnitySendMessage("GameObjectName","MethodName","Message");第二个方案借助AndroidJavaProxy类实现从Java回调到C#,有点类似于前文中涉及的从C/C++回调C#的方案。下面我们用一个例子来演示。首先是Java代码中定义了一个interface,并定义了一个doPrint方法供C#调用,当C#端调用doPrint之后,Java侧通过UnitySendMessage和interface两种方式回调C#
package com.netease.leihuo.demo
import com.unity3d.player.UnityPlayer;
public class HelloJava {
    //java interface对应C#端AndroidJavaProxy子类
    interface IJava2CSharpCallback {
        void onReturn(String msg);
    }
    //暴露给C#调用的方法
    public static void doPrint(IJava2CSharpCallback callback) {
        UnityPlayer.UnitySendMessage("Main","JavaCallback","msg from UnitySendMessage");//方式1,UnitySendMessage
        if (callback!=null) {//onReturn参数传递null值时,C#端需要重写AndroidJavaProxy的Invoke方法以避免出现No such proxy method问题
            callback.onReturn("msg from IJava2CSharpCallback");//方式2,AndroidJavaProxy
        }
    }
}C#端根据interface实现一个AndroidJavaProxy子类
public class Java2CSharpCallback : AndroidJavaProxy
{
    private event Action<string> m_OnReturn;
    private const string AndroidInterfaceName =
        "com.netease.leihuo.demo.HelloJava$IJava2CSharpCallback";//对应android中的接口
    public Java2CSharpCallback(Action<string> onReturnCallback): base(AndroidInterfaceName)
    {
        if (onReturnCallback != null) this.m_OnReturn += onReturnCallback;
    }
    void onReturn(string msg) //接口声明的方法
    {
        Loom.QueueOnMainThread(() =>{ m_OnReturn(msg); });//Loom负责将调用延迟到Unity主线程
    }
    //重写Invoke方法以避免Android String参数为null时找不到方法报错
    //Exception: No such proxy method: Java2CSharpCallback.onReturn(UnityEngine.AndroidJavaObject)
    public override AndroidJavaObject Invoke(string methodName, object[] args)
    {
        if (methodName != "onReturn") return base.Invoke(methodName, args);
        else { onReturn(args[0] as string); return null; }
    }
}调用及回调的示例如下
//挂在名为Main的GameObject上,接受来自Java端的SendMessage消息
void JavaCallback(string msg) {
    print("Callback from Java with msg " + msg);
}
//调用Java doPrint
void CallJava() {
    using (AndroidJavaClass jc = new AndroidJavaClass("com.netease.leihuo.demo.HelloJava")) {
        jc.CallStatic("doPrint", new Java2CSharpCallback((msg) =>{ print("Callback by AndroidJavaProxy with msg " + msg); }));
    }
}可以看到AndroidJavaProxy的方式和Delegate有异曲同工的效果,这里也同时演示了几个需要注意的情况:首先C#下面传递Android完整类名时,内部类型需要用$符号进行分隔,同时由于AndroidJavaProxy实现机制的原因,如果interface接口参数类型为string,并且有可能从Java端传递null值到C#端时,需要通过重写Invoke方法的方式来避免出错。
Java与C/C++的交互

Java调用C/C++
Java调用C/C++的情况属于纯粹的Android NDK开发范畴,在Unity3d Native开发中相对比较少见,实际工作中,我们通常会绕过Java,采用从C#调用C/C++的方式更直接。这里通过一个例子来简单介绍一下Java调用C/C++的实现,更多详细内容可以参见官方示例 Sample: hello-jni | Android NDK | Android Developers。如下Java代码片段所示,Java端代码主要包括三部分内容:

  • 在static语句块中加载C/C++实现的库libhellocpp.so。
  • 通过native关键词声明libhellocpp.so中定义的sum方法。
  • 通过callCpp调用libhellocpp.so中的方法。
package com.netease.leihuo.demo;
public class HelloJava{
    static {
        System.loadLibrary("hellocpp");
    }

    public native int sum(int a, int b);
       
    public void callCpp() {
        System.out.println("sum result is " + sum(1,2));
    }
}相应地,C/C++端方法的实现如下所示,其中包括了几个细节:

  • 方法的声明带有JNIEXPORT和JNICall导出标记。
  • 有一组j开头的类型来对应java的数据类型。
  • 方法的命名具有一定的规则,和调用者紧密耦合。
//JNIEXPORT标记方法可见性,JNICall标记方法的调用规约
JNIEXPORT jint JNICall Java_com_netease_leihuo_demo_HelloJava_sum(JNIEnv* env, jobject thiz, jint a, jint b) {
    return a + b;
}C/C++调用Java
C/C++调用Java实际上和C#以JNI方式调用Java本质上是一样的,这里我们模拟一下在C/C++下调用Java层UnityPlayer类的UnitySendMessage方法,该方法在前文Java与C#交互地环节有介绍,这里直接看C/C++部分地实现:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<jni.h>
#include <android/log.h>
#define TAG    "HelloCPP"
#define LOGE(...)  __android_log_print(ANDROID_LOG_ERROR,TAG,__VA_ARGS__)
static JNIEnv* jni_env = NULL;
//so加载阶段获取jni_env全局实例
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    if (vm->GetEnv((void**)&jni_env, JNI_VERSION_1_6) != JNI_OK){
        LOGE("get jni failed");
    }
    else {
        //java UnityPlayer.UnitySendMessage("Main","NativeCallback","C++ -> Java -> C#");
        jclass unity_player = jni_env->FindClass("com/unity3d/player/UnityPlayer");
        jmethodID static_method_id = jni_env->GetStaticMethodID(unity_player, "UnitySendMessage", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V");
        jstring gameobject_name = jni_env->NewStringUTF("Main");
        jstring method_name = jni_env->NewStringUTF("JavaCallback");
        jstring msg_content = jni_env->NewStringUTF("C++ -> Java -> C#");
        jni_env->CallStaticVoidMethod(unity_player, static_method_id, gameobject_name, method_name, msg_content);
    }
    return JNI_VERSION_1_6;
}可以看到写法上类似于我们在C#下面利用AndroidJNI/AndroidJNIHelper调用Java代码。
至此,我们将Unity3d Native开发中涉及的比较常见的跨语言交互形式做了一个详细的分析,接下来我们将以一个具有实践意义的案例来演示如何利用Unity3d Native开发的知识来解决实际问题。
实例分享——移动端动态权限管理

背景介绍

随着移动通信技术的飞速发展及各类移动设备的快速普及,移动APP已渗透到人们生活中的诸如社交、购物、办公、出行、娱乐等各个领域。同时,大数据、云计算、人工智能和物联网相关技术快速发展,企业对数据挖掘技术的利用不断深入,用户数据已成为企业发展的重要战略性资产。移动APP作为用户数据收集的主要入口之一,其用户个人信息保护问题已备受国家和社会重视。有关方面相继出台了一系列的权限使用指南用以规范权限相关的处理。综合各类指南(iOS|Android|其他)的具体内容,我们认为一个合理的权限处理应该包括以下几个方面原则:

  • 最小必要原则:只申请功能所必需的权限,不申请功能无关的权限
  • 用户可知原则:申请权限时向用户解释为什么需要相关权限
  • 非强制原则:允许用户拒绝或撤销权限,未授权的情况下适当降级功能以便让用户继续使用APP
  • 动态申请原则:只有在明确需要的时候请求用户权限,即在具体情境下请求权限
移动端游戏作为一种非常流行的APP,自然也被应用商店和监管部门要求在开发中遵循规范的权限处理流程,因此作为移动游戏开发者,不可避免地需要和权限机制打交道。
Unity3d权限机制概述

Unity针对移动端提供了一套权限管理的机制,但是这个机制因引擎版本而异。
iOS平台方面,从iOS 7.0发布开始,首次引入了动态权限机制,之后随着系统的升级,权限机制也在不断演化。在Unity 2018.1之前Unity3d没有提供权限处理的API,但是当开发者首次在功能中调用Unity3d的Microphone类或WebCamTexture类等涉及到权限相关的功能时,其内部会进行一次权限的判断及请求。从Unity 2018.1开始,iOS端可以通过Application类提供的HasUserAuthorization和RequestUserAuthorization接口判断和请求麦克风或摄像头的权限(无法用于处理诸如位置、相册等其他权限),这之后的Unity版本中权限API几无变化。
Android平台方面,从Android6.0开始,首次引入了运行时权限机制(也即动态权限机制),之后也是随系统版本的迭代而不断在完善。当APP运行在Android6.0及以上系统的设备中时,早期的Unity3d版本默认会在APP首次启动的时候一次集中申请所有危险权限(参见 Android 中的权限 | Android 开发者 | Android Developers),不管用户同意还是拒绝,之后都不会再进行权限请求。这个行为在早期的Unity版本中出现了Bug,参见Game does not work on new Android Oreo。可以通过在AndroidManifest中添加unityplayer.SkipPermissionsDialog信息来关闭这个默认的启动申请权限行为。从Unity2018.3开始,Unity3d提供了新的类Permission用于Andorid运行时动态权限的处理,但是它没有关于请求权限的结果回调(被授予、被拒绝、被拒绝并不再提示),所以使用非常不便。最早可追溯到Unity2020.2版本开始,Unity3d终于完善了Permission类,添加了回调,自此Android 权限的处理API才具有实用价值。
从以上介绍可以看出,Unity3d在处理移动端权限问题上具有版本及平台局限性,实际使用中存在诸多不足。为了适应iOS和Android系统权限机制的演进,更好地满足动态权限管理的需求,一个比较好的办法是自行开发维护一套权限管理的API。
自定义解决方案

在认识到不同平台权限机制的特点及Unity3d权限API存在的问题之后,我们提出一种新的解决方案,它独立于特定Unity版本、跨平台接口统一、使用方便、易于维护的特点,可以有效解决运行时权限处理的需求。其基本结构如下图所示



移动端动态权限处理基本结构图

方案的要点包括:针对Android端,通过在AndroidManifest中配置unityplayer.SkipPermissionsDialog来屏蔽Unity默认的启动时权限请求行为;在C#层屏蔽iOS及Android平台的各种权限方面的差异,暴露统一的接口给游戏逻辑使用;提供一组运行时权限处理的API,主要为如下三个接口:

  • HasUserPermission:判断用户是否已经授予了某个敏感权限
  • RequestUserPermission:请求用户授予某个敏感权限
  • ShowAppSettings:当拒绝授予权限后,引导用户打开系统设置中的APP设置来手动开启权限
后文我们将主要以摄像机为例进行权限方案的剖析。
统一接口处理

权限命名一致化
iOS和Android端对于权限命名存在差异,Android端根据不同权限名称用同一套API来处理授权问题,iOS端则是每种权限采用不同的API来处理授权问题。对于逻辑层应用来说,并不需要关注这个差异,因此这里通过条件编译的方式,在C#层利用名称来访问权限相关功能。权限名称的命名如下所示:
public class PermissionNames
{
#if UNITY_IOS && !UNITY_EDITOR
    public const string Camera = "CAMERA";
#else // Android
    public const string Camera = "android.permission.CAMERA";
#endif
    }授权状态一致化
iOS权限的状态分为Not Determined、Ahthorized、Denied、Restricted等,不同的权限对应有不同权限状态的定义,以摄像机权限状态枚举AVAuthorizationStatus为例,如下所示
typedef NS_ENUM(NSInteger, AVAuthorizationStatus) {
    AVAuthorizationStatusNotDetermined = 0,
    AVAuthorizationStatusRestricted  = 1,
    AVAuthorizationStatusDenied      = 2,
    AVAuthorizationStatusAuthorized  = 3,
} API_AVAILABLE(macos(10.14), ios(7.0), macCatalyst(14.0)) API_UNAVAILABLE(tvos) __WATCHOS_PROHIBITED;而Android端只有Granted和Denied之分,并且在PackageManager内中分别定义了如下常量
public static final int PERMISSION_DENIED;//Constant Value: -1 (0xffffffff)
public static final int PERMISSION_GRANTED;//Constant Value: 0 (0x00000000)此外,iOS端当用户拒绝了授予权限,之后APP再次请求该权限不会再出现系统弹窗;Android端在某些版本中,系统弹窗有一个不再询问的选项,仅当用户勾选了该选项并拒绝授予权限时,之后APP再次请求该权限才不会再出现系统弹窗(如下图所示)。



Android系统权限弹窗示例

在C#端,我们将权限的授予状态简单归类为授予(Granted)、未授予(Not Granted)两种状态,通过一个bool变量就可以表达;同时在请求权限时,提供了三种回调:被授予(Granted)、被拒绝(Denied)、被拒绝并且不再询问(DeniedAndDontAskAgain)。对于iOS端由于没有不再询问的选项,所以当用户拒绝后,默认回调DeniedAndDontAskAgain。
综合前述各种情况,最终我们可以将权限处理的功能封装成三个C#接口,接口的描述及调用示例如下所示:
//C#接口封装
public class UserPermissions
{
    public static bool HasUserPermission(string permission){ //调用平台相关的逻辑,下文分平台详细介绍 }
    public static void RequestUserPermission(string permission, Action<string> onGranted, Action<string> onDenied, Action<string> onDeniedAndDontAskAgain){ //调用平台相关的逻辑,下文分平台详细介绍 }
    public static void ShowAppSettings(){ //调用平台相关的逻辑,下文分平台详细介绍 }
}

//调用示例
if (UserPermissions.HasUserPermission(UserPermissions.PermissionNames.Camera))
{
    Debug.Log("permission " + UserPermissions.PermissionNames.Camera + "already granted");
}
else
{
    UserPermissions.RequestUserPermission(UserPermissions.PermissionNames.Camera, (permission) =>
    {
        Debug.Log("permission " + permission + " granted.");
    }, (permission) =>
    {
        Debug.Log("permission " + permission + " denied.");
    }, (permission) =>
    {
        Debug.Log("permission " + permission + " denied and dont ask again.");
        UserPermissions.ShowAppSettings();
     });
}接下来我们再花一些篇幅介绍一下iOS和Android端权限处理细节。
iOS权限封装

Unity对于iOS端的权限请求支持非常少,不过我们依然可以通过分析引擎源码来了解Unity3d在iOS端的权限处理细节。Unity3d安装目录的\Editor\Data\PlaybackEngines\iOSSupport\Trampoline下包括了很多iOS平台相关的功能封装,其中就有权限处理的源码,感兴趣的同学可以详细阅读一下。这里我们以摄像机权限为例实现一套iOS端权限处理接口。具体代码如下:

  • 回调采用Delegate方式,在iOS端定义函数指针PermissionGrantedCallback和PermissionDeniedCallback。其中wrapper参数是用于记录调用实例。
typedef void (*PermissionGrantedCallback)(const char * permission, intptr_t wrapper);
typedef void (*PermissionDeniedCallback)(const char * permission, intptr_t wrapper);

  • 声明并实现Native接口,用于供C#调用:
bool __HasUserPermission(const char * permission)
{
    NSString *permissionStr = [NSString stringWithUTF8String:permission];
    if([permissionStr isEqualToString:@"CAMERA"]) {
        return [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo] == AVAuthorizationStatusAuthorized;
    }
    return false;
}
void __RequestUserPermission(const char * permission, PermissionGrantedCallback onGranted, PermissionDeniedCallback onDenied, intptr_t wrapper)
{
    NSString *permissionStr = [NSString stringWithUTF8String:permission];
    char * temp = (char *)malloc(strlen(permission)+1);
    strcpy(temp, permission);
    if([permissionStr isEqualToString:@"CAMERA"]) {
        [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
            if(granted) onGranted(temp, wrapper);
            else onDenied(temp, wrapper);
            free(temp);}];
    }
    else {
        free(temp);
    }
}
void __ShowAppDetailSettings()
{
    [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil];
}在C#层面我们对IOS接口进行一层封装,封装的处理如下所示:
#if UNITY_IOS && !UNITY_EDITOR
class IOSPermissions
{
    public delegate void PermissionGrantedCallback(string permission, IntPtr wrapper);
    public delegate void PermissionDeniedCallback(string permission, IntPtr wrapper);

    [DllImport("__Internal")]
    public extern static bool __HasUserPermission(string permission);
    [DllImport("__Internal")]
    public extern static void __RequestUserPermission(string permission, PermissionGrantedCallback grantedCallback, PermissionDeniedCallback deniedCallback, IntPtr wrapper);
    [DllImport("__Internal")]
    public extern static void __ShowAppSettings();
    public class PermissionCallbacksWrapper
    {
        public event Action<string> PermissionGranted;
        public event Action<string> PermissionDenied;
        public PermissionCallbacksWrapper(){}
        [AOT.MonoPInvokeCallback(typeof(PermissionGrantedCallback))]
        public static void GrantedCallback(string permission, IntPtr wrapperPtr)
        {
            GCHandle handle = GCHandle.FromIntPtr(wrapperPtr);
            PermissionCallbacksWrapper wrapper = (PermissionCallbacksWrapper)handle.Target;
            wrapper.PermissionGranted?.Invoke(permission);
            handle.Free();
        }
        [AOT.MonoPInvokeCallback(typeof(PermissionDeniedCallback))]
        public static void DeniedCallback(string permission, IntPtr wrapperPtr)
        {
            GCHandle handle = GCHandle.FromIntPtr(wrapperPtr);
            PermissionCallbacksWrapper wrapper = (PermissionCallbacksWrapper)handle.Target;
            wrapper.PermissionDenied?.Invoke(permission);
            handle.Free();
        }
    }
    public static bool HasUserPermission(string permission)
    {
        return __HasUserPermission(permission);
    }
    public static void RequestUserPermission(string permission, PermissionCallbacksWrapper callbacks)
    {
        PermissionCallbacksWrapper wrapper = callbacks == null ? new PermissionCallbacksWrapper() : callbacks;
        GCHandle handle = GCHandle.Alloc(wrapper);
        __RequestUserPermission(permission, PermissionCallbacksWrapper.GrantedCallback, PermissionCallbacksWrapper.DeniedCallback, GCHandle.ToIntPtr(handle));
    }
    public static void ShowAppSettings()
    {
        __ShowAppSettings();
    }
}
#endif至此,就完成了一套用于C#端的IOS权限接口。
Android权限封装

Unity 2020.2及之后版本中,针对Android端的权限API已经发展的较为完善,基本上能够满足Android平台动态权限管理的需求。我们可以通过分析引擎源码来了解Unity3d权限处理细节。其中,在引擎安装目录\Editor\Data\PlaybackEngines\androidplayer\Variations\il2cpp\Development\Classes下有一个叫classes.jar的文件,利用Java Decompiler等工具可以获得对应java代码;同时在Github上Unity3d官方资源里可以找到权限API C#部分的代码(AndroidPermissions.cs),结合起来看就可以勾勒出Unity3d权限处理的全貌。这里我们借鉴Unity3d的方案来实现一套Android端权限处理接口。具体代码如下:

  • 回调采用AndroidJavaProxy方式,在Android端定义回调接口IPermissionRequestCallbacks。
package com.netease.leihuo.permission;
public interface IPermissionRequestCallbacks {
    void onPermissionGranted(String permissionName);
    void onPermissionDenied(String permissionName);
    void onPermissionDeniedAndDontAskAgain(String permissionName);
}

  • 定义一个PermissionFragment类用于具体处理权限请求逻辑。
public final class PermissionFragment extends Fragment {
    public static final String TAG = "PermissionFragment";
    public static final int REQUEST_CODE = 5201314;
    public static final String REQUEST_PERMISSION_NAMES = "PermissionNames";
    private final IPermissionRequestCallbacks callbacks;
    private final Activity act;
       
    public PermissionFragment(Activity activity, IPermissionRequestCallbacks callbacks){
        this.act = activity;
        this.callbacks = callbacks;
    }
    @Override
    public final void onCreate(Bundle savedInstanceState){
        super.onCreate(savedInstanceState);
        String[] permissionNames = getArguments().getStringArray(REQUEST_PERMISSION_NAMES);
        requestPermissions(permissionNames, REQUEST_CODE);
    }
    @Override
    public final void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        if(requestCode != REQUEST_CODE)
            return;
        if(this.callbacks == null)
            return;
        for(int i=0; i<permissions.length && i<grantResults.length; i++) {
            String permission = permissions;
            int grantResult = grantResults;
            if(grantResult == PackageManager.PERMISSION_DENIED) {
                if (this.act.shouldShowRequestPermissionRationale(permission)) {
                    this.callbacks.onPermissionDenied(permission);//拒绝授权但没有勾选不再显示
                }
                else {
                    this.callbacks.onPermissionDeniedAndDontAskAgain(permission);//拒绝授权并勾选过不再提示
                }
            }
            else if(grantResult == PackageManager.PERMISSION_GRANTED) {
                this.callbacks.onPermissionGranted(permission);//已授予权限
            }               
        }       
        FragmentTransaction transaction = getFragmentManager().beginTransaction().remove(this);
        transaction.commit();
    }
}

  • 把前面的内容结合起来实现UserPermissions类用于C#端调用。
public final class UserPermissions {
    public static boolean hasUserPermission(Activity activity, String permission) {
        return activity.checkCallingOrSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
    }
    public static void requestUserPermissions(Activity activity, String[] permissions, IPermissionRequestCallbacks callbacks) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
            return;     
        if (activity == null || permissions == null || permissions.length == 0)
            return;
        FragmentManager fragmentManager = activity.getFragmentManager();
        String tag = "5201314";
        if (fragmentManager.findFragmentByTag(tag) == null) {
            PermissionFragment f = new PermissionFragment(activity, callbacks);
            Bundle bundle = new Bundle();
            bundle.putStringArray(PermissionFragment.REQUEST_PERMISSION_NAMES, permissions);
            f.setArguments(bundle);
            FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
            fragmentTransaction.add(0, f, tag);
            fragmentTransaction.commit();
        }
    }
    public static void showAppSettings(Activity activity) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
            Intent intent = new Intent();
            intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
            Uri uri = Uri.fromParts("package", activity.getPackageName(), null);
            intent.setData(uri);
            activity.startActivity(intent);
        }
    }
}

  • C#端定义IPermissionRequestCallbacks对应的PermissionCallbacks。
public class PermissionCallbacks : AndroidJavaProxy
{
    public event Action<string> PermissionGranted;
    public event Action<string> PermissionDenied;
    public event Action<string> PermissionDeniedAndDontAskAgain;
    public PermissionCallbacks(): base("com.netease.leihuo.permission.IPermissionRequestCallbacks") { }
    private void onPermissionGranted(string permission) { PermissionGranted?.Invoke(permission); }
    private void onPermissionDenied(string permission) { PermissionDenied?.Invoke(permission); }
    private void onPermissionDeniedAndDontAskAgain(string permission)
    {
        if (PermissionDeniedAndDontAskAgain != null) PermissionDeniedAndDontAskAgain(permission);
        else  PermissionDenied?.Invoke(permission);
    }
}

  • C#实现AndroidPermissions类对Java接口进行封装(具体实现参见C#与Java交互章节,此处省略)。
#if UNITY_ANDROID && !UNITY_EDITOR
class AndroidPermissions
{
    private static AndroidJavaObject m_Activity;
    private static AndroidJavaObject m_UserPermissions;
    private static AndroidJavaObject GetActivity() { //获取UnityPlayer currentActivity对象 }
    private static AndroidJavaClass GetUserPermissions() { //获取com.netease.leihuo.permission.UserPermissions类 }
    public static bool HasUserPermission(string permission) { //调用Java hasUserPermission方法 }
    public static void RequestUserPermission(string permission, PermissionCallbacks callbacks) { //调用Java requestUserPermissions方法 }
    public static void ShowAppSettings() { //调用Java showAppSettings方法 }
}
#endif至此,就完成了一套用于C#端的Android权限接口。
Native回调方案比较

从前文我们可以知晓,Native回调Unity3d共有两种方案可以选择,分别是UnitySendMessage和Delegate(广义上理解包括了MonoPInvokeCallback和AndroidJavaProxy)。下面对这两个方案优缺点做一个简短地归纳。
UnitySendMessage优点:

  • 使用简单,可以基于json传递复杂数据
  • 按名称访问方法,耦合度低,易于扩展
  • 回调方法执行在Unity3d主线程,避免了子线程调用Unity3d API的限制
UnitySendMessage局限:

  • 按名称访问的限定存在重名问题,实际应用情景需要保证响应方法唯一
  • 只提供了iOS端和Android Java端接口,对于Android C/C++和Windows端需要自行开发
  • 方法延迟一帧执行,不能满足同步要求,常规使用方式打断代码书写逻辑(调用和回调分散到两个方法)
  • 参数只能以字符串传递,参数越复杂、调用越频繁GC问题越明显(UnitySendMessageEfficiencyTest)
  • 多个调用方调用同一个Native方法时需要传递context信息,用于回调时区分不同的调用方
Delegate优点:

  • 可同步执行
  • 代码逻辑书写更方便(调用和回调可以写在同一个方法里面)
  • 可以更高效传递复杂的数据,也可以有返回值,传参方面具有更好的性能
Delegate局限:

  • 实现比较复杂
  • 跨语言调用的两端需要定义相同的接口,耦合度高
  • 回调方法不一定在Unity3d主线程执行,如需主线程执行则C#侧需借助类似Loom类机制实现
这两种方案具体该如何选择呢?个人认为,对于一些接口可能需要频繁变动的SDK、或者对性能要求不高的Native功能,可以选择UnitySendMessage方案;对于接口比较固定,性能要求比较高的情况,可以选择Delegate方案。而对于Windows平台来说,只有Delegate方案可选,具体应用时,也可以利用Delegate模拟UnitySendMessage的行为。
Native调用线程问题

Unity3d中大多数API被设计为只能在引擎主线程执行,与此同时,Native平台提供的某些API也被设计为只能在特定线程(比如UI线程)中执行。当处理Native调用及回调问题时,我们需要特别留意将代码放置到正确的线程去执行,否则会导致程序运行异常。
以Android平台为例,Android的UI线程和Unity3d主线程是两个不同的线程,如果从Android的UI线程通过Delegate机制回调Unity3d一侧的C#方法,并且回调方法中包含了只能在Unity3d主线程执行的API,就会触发报错“xxx can only be called from the main thread”。此外,当我们在Unity3d端通过Native机制调用涉及Android原生UI相关API时,也需要将API的调用放在Android UI线程执行。关于此类问题的一些细节可以参见这两篇问答Use Unity API from another Thread or call a function in the main Thread 和 Unity3D & Android: Difference between "UnityMain" and "main" threads?
这里重点介绍一下从Native回调到Unity3d时如何将回调的代码放到Unity3d主线程执行。解决思路也比较简单,我们可以将回调的方法包装成一个C#的Action对象,并将其push到一个队列,这个队列中的Action对象只在MonoBehavior的Update方法中执行。具体的实现思路可以参考GitHub上的一个例子unity/Loom.cs at master · ufz-vislab/unity。我们在前文多次提到的Loom类就参考了它的实现。
总结

本文介绍了在Unity3d中进行Native开发时涉及的一些技术知识,包括Unity原生插件机制,各种跨语言调用规则等内容,并通过一个移动端动态权限管理API封装的案例来介绍了如何使用相关知识来解决实际问题。Native开发涉及的技术点非常多,一篇文章很难面面俱到,这里笔者从自己的角度尝试进行了讲解,希望对大家在处理类似问题时能够提供一些启发和帮助。
参考资料

https://docs.unity3d.com/Manual/NativePlugins.html
https://docs.unity3d.com/Manual/PluginsForIOS.html
Create and use plug-ins in Android
Building plug-ins for desktop platfor

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-10-2 19:51 | 显示全部楼层
大佬又来发文了!!!
发表于 2022-10-2 19:53 | 显示全部楼层
[思考]用这种方式和C#有什么区别
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-17 10:59 , Processed in 0.095032 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表