Unity3D ZFBrowser (EmbeddedBrowser) 插件嵌入网页无法输入中文问题

Posted tiancaiKG

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity3D ZFBrowser (EmbeddedBrowser) 插件嵌入网页无法输入中文问题相关的知识,希望对你有一定的参考价值。

  网页嵌入插件最好的应该就是ZFBrowser了, 可是使用起来也是问题多多, 现在最要命的是网页输入不能打中文, 作者也没打算接入IME, 只能自己想办法了...

  搞了半天只想到一个办法, 就是通过Unity的IME去触发中文输入, 然后传入网页, 也就是说做一个透明的 InputField 盖住网页的输入文本框, 然后在 Update 或是 onValueChanged 中把内容传给网页, 这样基本就能实现中文输入了.

  因为对前端不熟悉, 我就做了一个简单网页做测试:

<html>

<head>
    <title>My first page</title>
    <style>
        body {
            margin: 0;
        }
    </style>
</head>

<body>
    <h1>Test Input</h1>
    Field1:    <input type="text" id="field1"> 
    Field2:    <input type="text" id="field2"> 
    <br>
    <br>
    <script>
        function SetInputValue(id, str) {
            document.getElementById(id).value = str;
        }
        function SubmitInput(str)
        {
            document.getElementById("field2").value = "Submited : " + str;
        }
    </script>

</body>

</html>

  这里网页有两个Text Area, 左边作为输入, 右边作为回车后的调用测试:

 

  然后Unity中直接用一个InputField放到 Field1 的位置上, 设置为透明, 通过Browser类提供的CallFunction方式调用就可以了:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

namespace UIModules.UITools
{
    public class BrowserInputField : MonoBehaviour
    {
        [SerializeField]
        public ZenFulcrum.EmbeddedBrowser.Browser browser;
        [SerializeField]
        public InputField input;

        [Space(10.0f)]
        [Header("设置网页 input 函数名称")]
        [SerializeField]
        public string SetInputFuncName = "SetInputFuncName";
        [Header("设置网页 submit 函数名称")]
        [SerializeField]
        public string SubmitFuncName = "SubmitFuncName";
        [Header("网页 input id")]
        [SerializeField]
        public string InputElementID = "InputElementID";

        public bool inited { get; private set; }

        private void Awake()
        {
            this.RequireComponent<CanvasGroup>().alpha = 0.01f;
            Init();
        }

        public void Init()
        {
            if(input && (false == inited))
            {
                inited = true;
                input.RequireComponent<IME_InputFollower>();       // IME 跟随

                StartCoroutine(CaretAccess((_caret) =>
                {
                    if(_caret)
                    {
                        var group = _caret.RequireComponent<CanvasGroup>();
                        group.alpha = 1f;
                        group.ignoreParentGroups = true;
                    }
                }));
            }
        }

        IEnumerator CaretAccess(System.Action<Transform> access)
        {
            if(input)
            {
                var caret = input.transform.Find("InputField Input Caret");
                while(caret == false && input)
                {
                    caret = input.transform.Find("InputField Input Caret");
                    yield return null;
                }
                access.Invoke(caret);
            }
        }

        void Update()
        {
            if(browser && input)
            {
                browser.CallFunction(SetInputFuncName, new ZenFulcrum.EmbeddedBrowser.JSONNode[2]
                {
                    new ZenFulcrum.EmbeddedBrowser.JSONNode(InputElementID),
                    new ZenFulcrum.EmbeddedBrowser.JSONNode(input.isFocused ? input.text : (string.IsNullOrEmpty(input.text)?input.placeholder.GetComponent<Text>().text: input.text))
                });
            }
        }
    }
}

  这里InputField它会自动生成 Caret 就是输入标记, 为了让他能显示出来, 需要等待到它创建出来之后设置透明度即可. 这里省掉了IME输入法跟随的代码, 那是其它功能了.

  恩, 因为字体大小不一样, 所以Caret位置不准确, 反正是能输入了.

 

  这是静态的写法, 可以手动去摆 InputField, 可是在很多情况下是不适用的, 比如 Scroll View 里面的元素, 就需要动态去获取了, 可是由于我们无法计算出网页 input 的位置, 所以没法动态地去设置一个InputField来对上网页, 如果对输入标记没有要求的话 (就是那个打字时候会闪的 "|" 竖杠) , 就可以通过注册网页 input 的 onFocus 方法, 来 focus 一个 InputField, 从而触发输入法, 然后再像上面一样监测输入就行了, 而且不需要在网页端写输入函数来调用了, 这个函数我们应该也是可以自己注册进去的...

  这个想法很好, 来测试看看能不能获取网页中的所有 input 节点吧, 在网页那边写测试(因为我确实没写过网页...) : 

// ... 省略代码了
    Field1: <input type="text" id="field1">
    Field2: <input type="text" id="field2">
    <button type="button" onclick="Click()">Get ID</button>
    <br>
    <p id="show">Show</p>
    <script>
        function Click() {
            var inputs, index;
            var showInfo = "";
            inputs = document.getElementsByTagName(\'input\');
            for (index = 0; index < inputs.length; ++index) {
                showInfo = showInfo + inputs[index].id + " ";
            }
            document.getElementById("show").innerHTML = showInfo;
        }
    </script>
// ...

  没错是能获取ID, 那我从Unity那边来添加这个函数试试:

    private void OnGUI()
    {
        if(GUI.Button(new Rect(500, 100, 100, 50), "TestClick"))
        {
            var script = @"
function TestClick() {
    var inputs, index;
    var array = new Array();
    inputs = document.getElementsByTagName(\'input\');
    for (index = 0; index < inputs.length; ++index) {
        array.push(inputs[index].id);
    }
    return array;
}";
            var inputs = browser.EvalJS(script);
            if(inputs != null)
            {
                inputs.Done((_value) =>
                {
                    if(_value != null)
                    {
                        Debug.Log(_value.ToString());
                    }

                    var retVal = browser.CallFunction("TestClick");
                    if(retVal != null)
                    {
                        retVal.Done((_ret) =>
                        {
                            if(_ret != null)
                            {
                                Debug.Log(_ret.ToString());
                            }
                        });
                    }
                });
            }
        }
    }

  我创建了一个 TestClick 方法, 通过 EvalJS 解释到网页中, 还好这些解释语言的套路都差不多, 只是不知道它给我返回的是啥, 第一个解释 js function的返回有点意外, 居然是个空 :

  不过没关系, 后面的函数调用返回是我要的 : 

  不错, 返回了我要的节点名称, 这样函数就注册进去然后调用成功了, 说明确实可以通过注入式的代码完成调用, 然后我只需要把另一个设置 input 内容的代码注入进去, 就可以随时修改所有 input 对象了.

// 本作核心代码
    function SetInputValue(id, str) {
        document.getElementById(id).value = str;
    }

  马上加进去看看, 先整合一下代码把请求提取出来 : 

    public static void WebBrowserFunctionRegister(ZenFulcrum.EmbeddedBrowser.Browser browser, string function, System.Action<ZenFulcrum.EmbeddedBrowser.JSONNode> succ = null)
    {
        if(browser)
        {
            var register = browser.EvalJS(function);
            if(register != null)
            {
                register.Done((_value) =>
                {
                    if(succ != null)
                    {
                        succ.Invoke(_value);
                    }
                });
            }
        }
    }
    public static void WebBrowserFunctionCall(ZenFulcrum.EmbeddedBrowser.Browser browser, string functionname, ZenFulcrum.EmbeddedBrowser.JSONNode[] param,
        System.Action<ZenFulcrum.EmbeddedBrowser.JSONNode> result = null)
    {
        if(browser)
        {
            var retVal = param != null && param.Length > 0 ? browser.CallFunction(functionname, param) : browser.CallFunction(functionname);
            if(retVal != null)
            {
                retVal.Done((_ret) =>
                {
                    if(result != null)
                    {
                        result.Invoke(_ret);
                    }
                });
            }
        }
    }

    private void OnGUI()
    {
        if(GUI.Button(new Rect(500, 100, 100, 50), "TestClick"))
        {
            var testClick = @"
function TestClick() {
    var inputs, index;
    var array = new Array();
    inputs = document.getElementsByTagName(\'input\');
    for (index = 0; index < inputs.length; ++index) {
        array.push(inputs[index].id);
    }
    return array;
}";
            var coreScript = @"
    function SetInputValue(id, str) {
        document.getElementById(id).value = str;
    }
";
            WebBrowserFunctionRegister(browser, testClick, (_) =>
            {
                WebBrowserFunctionCall(browser, "TestClick", null, (_ret) =>
                {
                    WebBrowserFunctionRegister(browser, coreScript, (__) =>
                    {
                        var list = LitJson.JsonMapper.ToObject<List<string>>(_ret.AsJSON);
                        if(list != null)
                        {
                            foreach(var id in list)
                            {
                                WebBrowserFunctionCall(browser, "SetInputValue", new ZenFulcrum.EmbeddedBrowser.JSONNode[2] {
                                    new ZenFulcrum.EmbeddedBrowser.JSONNode(id),
                                    new ZenFulcrum.EmbeddedBrowser.JSONNode("测试:" + id),
                                });
                            }
                        }
                    });
                });
            });
        }
    }

  因为我不确定它是不是都是异步的, 所以都用回调的形式来做了, 结果喜人, 确实能够正确运行了:

  几乎成了, 下一步就是注册一下 input 的 focus 事件, 在网页触发 focus 之后就创建一个 InputField 按照套路走就行了, 在 InputField 的focus取消的时候销毁它, 就能完美解决输入法问题了...

 -------------------------------------------------------------------------------------------------

(2020.7.7)

  之前的理论没有问题, 不过可以更加简化一点, 首先 ZFBrowser 解析的网页, 它的 focus 跟 InputField 中的 focus 并不冲突, 并且在两边都 focus 的情况下, 网页接收的输入就是 Unity 调用的 IME, 所以就不需要同步 InputField 中的输入到网页那边了, InputField 只作为启动 IME 的入口即可.

  然后发现很多网页中的 input 元素并不使用 id, 而是直接 class 设置了调用逻辑, 比较面向过程, 而且W3C标准中, 每个控件或者元素, 并没有一个GUID, 这就无法通过唯一ID定位到某个元素上了 ( [对Web页面元素的绝对唯一引用方法] https://www.cnblogs.com/birdshome/archive/2006/09/28/uniqueid_usage.html )...

  那么我们想要获取和设置某个 input 的元素的时候, 就需要自己给没有 id 的 input 元素添加ID了.

  然后一个元素的调用函数, 不像C#中的delegate那么方便, 你要添加一个唯一调用, 只需要删除原有回调再添加即可:

    browser.onConsoleMessage -= OnConsoleMessage;
    browser.onConsoleMessage += OnConsoleMessage;

  C#怎么样都不会错误添加多个同样的回调. 可是JS没有这个, 有些人自己写了相似的, 可是不是面向对象, 肯定会出错.

 

  首先来看看怎样给 input 元素添加 id, 然后添加 onfocus 方法给它, 让它在焦点的时候能够通知到 Unity 来创建 InputField 触发 IME.

  /* 创建唯一ID代码 */

    public const string InjectInputID_JS_Name = "InjectInputID";
    public const string InjectInputID_JS = @"
var inputID = 1;
function InjectInputID() {
    var inputs, index;
    inputs = document.getElementsByTagName(\'input\');
    for (index = 0; index < inputs.length; ++index) {
        var rawID = inputs[index].id;
        if(rawID == null || rawID == \'\'){
            inputs[index].id = \'custom_input_id_\' + (inputID++);
        }
    }
}";

// 某处调用注册函数
WebBrowserFunctionRegister(browser, InjectInputID_JS);

  上面的注入ID代码使用了一个全局变量 inputID, 这样在设置时就能避免网页动态加载出来的新元素得到同样的ID了.

 

  /* 添加回调事件方法 */

    public const string AddEventFunc_JS_Name = @"AddEventFunc";
    public const string eventFuncNameTemplate_JS = "EVENTFUNC";
    public const string eventTemplateName_JS = "EVENTNAME";
    public const string AddEventFuncTemplate_JS = @"
function AddEventFunc(elementID) 
{
    var tagElement = document.getElementById(elementID);
    if (tagElement != null) 
    {
        var oldFuncStr = (tagElement.EVENTFUNC + \'\').replace(/(\\n)+|(\\r\\n)+/g, \'\');
        var rawFunc = oldFuncStr.substring(oldFuncStr.indexOf(\'{\') + 1, oldFuncStr.indexOf(\'}\'));
        var newFunc = function() 
        {
            eval(rawFunc);
            console.log(elementID + \':EVENTNAME\');
        }
        if((newFunc + \'\').replace(/(\\n)+|(\\r\\n)+/g, \'\') == oldFuncStr){
            return;
        }
        tagElement.EVENTFUNC = newFunc;
    }
}";

    public enum ElementEventFunc
    {
        onclick,
        onsubmit,
        onfocus
    }
    public static string GenerateAddEventFunc_JS_Code(ElementEventFunc eventFunc, string customEvent)
    {
        return GenerateAddEventFunc_JS_Code(eventFunc.ToString(), customEvent);
    }
    public static string GenerateAddEventFunc_JS_Code(string eventFunc, string customEvent)
    {
        return AddEventFuncTemplate_JS.Replace(eventFuncNameTemplate_JS, eventFunc).Replace(eventTemplateName_JS, customEvent);
    }

// 某处调用注册函数
    string focusFunc_JS = GenerateAddEventFunc_JS_Code(ElementEventFunc.onfocus, ElementEventFunc.onfocus.ToString() + ":" + browser.GetHashCode().ToString());
    WebBrowserFunctionRegister(browser, focusFunc_JS);

  这里使用了一个模板来创建 function, 因为考虑到以后可能会使用到其它事件的注册, 不一定只有 focus 的.

  说来浏览器的解释执行代码也挺神奇的, 一个函数可以直接以字符串的方式获取, 好像叫Blob, 反正就像上面代码中的, 比如是 onfocus 函数, 那么就成了:

var oldFuncStr = (tagElement.onfocus + \'\').replace(/(\\n)+|(\\r\\n)+/g, \'\');

  这样就把 onfocus 的调用方法字符串得到了, 像是下面这样:

<input type="text" id="field1", onfocus="OnFocus(this.id)">
<script>
    function OnFocus(id){
        console.log(id);
    }
    function Test() {
        var tag  = document.getElementById(\'field1\');
        console.log((tag.onfocus + \'\').replace(/(\\n)+|(\\r\\n)+/g, \'\'));
    }
    Test() 
</script> 

  得到的 onfocus 字符串 : 

function onfocus(event) {  OnFocus(this.id)}

  然后就是把里面的方法取出来, 封装到新的方法里面去, 当然原有方法是字符串, 必须使用 eval 来进行编译调用:

    var rawFunc = oldFuncStr.substring(oldFuncStr.indexOf(\'{\') + 1, oldFuncStr.indexOf(\'}\'));
    var newFunc = function() 
    {
        eval(rawFunc);
        console.log(elementID + \':onfocus:XXXX\');    // 这里运行时XXXX被设置为unity Browser对象的哈希值
    }
    if((newFunc + \'\').replace(/(\\n)+|(\\r\\n)+/g, \'\') == oldFuncStr){
        return;
    }
    tagElement.onfocus = newFunc;                    // 

  newFunc 就包含了老函数调用和新的 Log, 我们就是以监听 log 来发送消息的, 中间有个比较 newFunc 和 oldFuncStr 的逻辑, 因为它不像delegate那样可以不重复添加回调, 并且 JS 的回调会包含闭包信息之类的, 如果这个添加回调的添加了两次, 它会造成死循环, 我不是很清楚为什么, 所以判断相同的回调时不再进行添加. 这里就限定了只能添加一次回调, 逻辑是有问题的, 不过本工程中使用上已经够了.  

  这样就能注册并监听网页 input 元素的 onfocus 事件了. 详细注册方法如下, 因为网页回调的log必须带有对应的网页ID, 才能分清是哪个网页的 input 被焦点了:

    private Dictionary<Browser, HashSet<string>> m_focusTargets = new Dictionary<Browser, HashSet<string>>();
    
    public const string GetInputs_JS_Name = "GetInputs";
    public const string GetInputs_JS = @"
function GetInputs() {
    var inputs, index;
    var array = new Array();
    inputs = document.getElementsByTagName(\'input\');
    for (index = 0; index < inputs.length; ++index) {
        array.push(inputs[index].id);
    }
    return array;
}";
    
    private void RegisterBaseFunctions(Browser browser)
    {
        browser.onConsoleMessage -= OnFocus;
        browser.onConsoleMessage += OnFocus;

        WebBrowserFunctionRegister(browser, InjectInputID_JS);
        string focusFunc_JS = GenerateAddEventFunc_JS_Code(ElementEventFunc.onfocus, ElementEventFunc.onfocus.ToString() + ":" + browser.GetHashCode().ToString());
        WebBrowserFunctionRegister(browser, GetInputs_JS);
    }
    
    private void OnFocus(string msg, string src)
    {
        if(string.IsNullOrEmpty(msg))
        {
            return;
        }
        Debug.Log("OnFocus msg : " + msg);
        var sp = msg.Split(\':\');
        if(sp != null && sp.Length >= 3)
        {
            var id = sp[0];
            var hashCode = sp[2];
            switch(sp[1])
            {
                case "onfocus":
                    {
                        OnFocus(GetBrowserByHash(hashCode), id);
                    }
                    break;
            }
        }
    }
    
    public Browser GetBrowserByHash(string hashCode)
    {
        foreach(var browser in m_scanTargets.Keys)
        {
            if(browser && string.Equals(browser.GetHashCode().ToString(), hashCode, System.StringComparison.Ordinal))
            {
                return browser;
            }
        }
        return null;
    }
    
    private void OnFocus(Browser browser, string id, string text = null)
    {
        if(browser)
        {
            // ......
        }
    }    

  因为网页会动态加载或者创建元素, 所以获取 input 和注入回调需要在update或者协程中不断地获取, 来保证每个 input 的回调正确...

  如果 input 的 id 是 "field1", 那么回调中传回来的 message 就是 "field1:onfocus:XXXX" ,  XXXX就是 browser.GetHashCode().ToString() 

  在协程中去不断检测是否有新 input 元素:

    // 在某处调用
    StartCoroutine(CheckWebInput());
    
    private IEnumerator CheckWebInput()
    {
        while(true)
        {
            yield return null;
            foreach(var tags in m_focusTargets)
            {
                var browser = tags.Key;
                BrowserInputCheck(browser, tags.Value);
            }
        }
    }    
        
    // 因为网页可能动态加载, 我们需要不断地获取网页 input 元素, 来进行注册 onfocus 回调
    private void BrowserInputCheck(Browser browser, HashSet<string> exists)
    {
        if(browser && browser.IsLoaded)
        {
            WebBrowserFunctionCall(browser, InjectInputID_JS_Name, null, (_) =>
            {
                WebBrowserFunctionCall(browser, GetInputs_JS_Name, null, (_ret) =>
                {
                    var inputIDs = LitJson.JsonMapper.ToObject<List<string>>(_ret.AsJSON);
                    if(inputIDs != null && inputIDs.Count > 0)
                    {
                        foreach(var inputID in inputIDs)
                        {
                            if(exists.Contains(inputID) == false)
                            {
                                exists.Add(inputID);
                                WebBrowserFunctionCall(browser, AddEventFunc_JS_Name, new JSONNode[1] { new JSONNode(inputID) });
                            }
                        }
                    }
                });
            });
        }
    }

 

  当可以正常收到 onfocus 事件之后, 只需要对应创建 InputField 组件, 然后同样把 Unity 的 Focus 给这个 InputField 就行了, 至于 html 的 blur (丢失焦点) 事件, 这里是不用监听的, 因为 InputField 同样会因为鼠标操作, 键盘 Enter/Return 按键, ESC 按键触发 OnEditEnd 并丢失焦点, 所以我们只需要监听没有丢失焦点的情况即可.

  在什么情况下 Unity的 InputField会丢失焦点而网页 input 不会丢失焦点呢? 测试了一下包含以下情形:

  1. Enter / Return / ESC 键盘都触发了 InputField 的丢失焦点, 可是网页并不一定会丢失焦点.

  2. 鼠标移出网页显示的UI区域, 网页会丢失焦点, 不过鼠标移回网页它会自动触发 onfocus, 这都没有问题, 可是如果用户再次点击网页 input 区域, 不会再次触发 onfocus 事件, 此时由于点击操作 InputField 会丢失焦点.

 

  应对这些情况, 就需要做对应修改, 在 InputField 的 onEndEdit 回调中, 添加相关测试以及操作 (回调经过封装处理, 变量 BrowserInputField 包含了相关组件引用) : 

    public const string HasFocusFunc_JS_Name = "GetHasFocus";
    public const string HasFocusFunc_JS = @"
function GetHasFocus(id) {
    var target = document.getElementById(id);
    return (target != null && target.id == document.activeElement.id);
}";
    public const string CancelFocus_JS_Name = "CancelFocus";
    public const string CancelFocus_JS = @"
function CancelFocus() {
    document.activeElement.blur();
}";
    
    // 在某处进行注册
    WebBrowserFunctionRegister(browser, HasFocusFunc_JS);
    WebBrowserFunctionRegister(browser, CancelFocus_JS);
    
    // 封装后的 InputField.onEndEdit 回调. BrowserInputField 包含相关组件
    private void OnEditEnd(BrowserInputField inputField)
    {
        if(inputField)
        {
            if(Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter) || Input.GetKeyDown(KeyCode.Escape))
            {
                WebBrowserFunctionCall(inputField.browser, CancelFocus_JS_Name, null);
            }
            else
            {
                var ui = MathTools.GetMouseOnUI();
                if(ui)
                {
                    var guiData = ui.GetComponent<RuntimeData.BrowserGUINetData>();
                    if(guiData && guiData.browser == inputField.browser)
                    {
                        Core.CoroutineRoot.instance.RunWaitFrames(2, () =>
                        {
                            WebBrowserFunctionCall(inputField.browser, HasFocusFunc_JS_Name, new JSONNode[1] { new JSONNode(inputField.InputElementID) }, (_ret) =>
                            {
                                Common.DataTable value = _ret.AsJSON;
                                if((bool)value)
                                {
                                    inputField.FocusInputField();
                                }
                            });
                        });
                    }
                }
            }
        }
    }

  1. 当Enter / Return / ESC 键盘都触发了 InputField 的丢失焦点, 强行对网页当前的焦点执行 blur 操作, 禁止没有IME的输入继续输入网页.

  2. 当鼠标还在网页UI区域时, 如果丢失了焦点就检测对应ID的 input 是否也丢失了焦点, 如果没有就重新焦点到 InputField. 

  这样解决了焦点问题之后, 输入 中文 / 日文 这些需要IME支持的语言就能正确输入了, 当然如果看了源代码知道怎样直接触发IME的话, 可以把 InputField 省略掉. Unity 怎样Focus目标的代码:

        public void FocusInputField()
        {
            if(input)
            {
                if (EventSystem.current.currentSelectedGameObject != input.gameObject)
                {
                    EventSystem.current.SetSelectedGameObject(input.gameObject, null);
                }            
                input.OnPointerClick(new PointerEventData(EventSystem.current));
            }
        }

 

 

以上是关于Unity3D ZFBrowser (EmbeddedBrowser) 插件嵌入网页无法输入中文问题的主要内容,如果未能解决你的问题,请参考以下文章

打包的时候出现 Embedded binary is not signed with the same certificate as the parent app. Verify the embedd

record

embedding层报错

Emmet

Font Awesome 字体支持 ie

#yyds干货盘点# Embedding matrix