Об использовании WebKit .NET

в 4:38, , рубрики: javascript

Введение

Связка HTML+CSS+JavaScript на сегодняшний день зарекомендовала себя как универсальный способ построения пользовательских интерфейсов. Причем не только в веб приложениях, но также в десктоп и мобильных приложениях. Примерами тому являются metro-приложения в Windows 8, фреймворк PhoneGap для создания мобильных приложений.

Реализация интерфейса с помощью HTML, CSS и JavaScript прежде всего подразумевает, что интерфейс будет отображаться в некотором браузере. Если мы рассматриваем десктоп или мобильное приложение, то, очевидно, браузер должен быть встраиваемым.

В данной статье мы рассмотрим использование WebKit .NET в десктоп приложении на C# под Windows.

Требования к встраиваемому браузеру

Выявим основные требования к встраиваемому браузеру.

Прежде всего, из C# мы должны иметь возможность устанавливать HTML документу произвольное содержимое — т. е. наш интерфейс. Далее естественным образом возникает потребность в коммуникации между C# и JavaScript. Необходимо иметь возможность вызывать C# из JavaScript и JavaScript из C#, причем двумя способами — синхронно и асинхронно. Наше десктоп приложение с интерфейсом, реализованным во встраиваемом браузере, во многом похоже на традиционное веб приложение, имеющее клиент-серверную архитектуру. Поэтому наличие асинхронных вызовов C# из JavaScript очень важно. Эти вызовы представляют собой аналог AJAX запросов в традиционном веб приложении.

Веб инспектор и отладчик — лучший друг каждого веб разработчика. А как с этим обстоит дело здесь? Скажем сразу — здесь с этим не все так просто. Нам не удалось найти способ отлаживать JavaSсript, выполняющийся во встраиваемом браузере, но мы нашли возможность исследовать dom и получили JavaScript консоль.

Таким образом, основные требования к WebKit .NET заключались в наличии следующих вещей:

  • возможность устанавливать HTML документу произвольное содержимое
  • вызовы JavaScript из C# синхронно или асинхронно
  • вызовы C# из JavaScript синхронно или асинхронно
  • веб инспектор

Далее мы рассмотрим, что из вышеперечисленного присутствовало в WebKit .NET и как недостающие функции были реализованы.

Установка содержимого HTML документа

Свойство WebKitBrowser.DocumentText позволяет установить HTML документу произвольное содержимое. Наш интерфейс полностью независим от внешних ресурсов, весь JavaScript и CSS мы включаем непосредственно в HTML. Для увеличения производительности все скрипты включаются в минифицированном виде.

Вызовы JavaScript из C#

Для вызовов JavaScript из C# в WebKit .NET имеется метод Document.InvokeScriptMethod со следующей сигнатурой:

public Object InvokeScriptMethod(
    string Method,
    params Object[] args
)

К сожалению, данный метод имеет проблему с передачей параметров в JavaScript функцию — она просто не работает.

Чтобы решить эту проблему нам пришлось разработать свой собственный протокол вызовов JavaScript из C#. Идея заключается в следующем:

  • для передачи параметров используется div с заданным идентификатором
  • C# сериализует имя и массив параметров для JavaScript функции в JSON строку и помещает ее в заданный div
  • JavaScript извлекает JSON, десериализует его и вызывает указанную функцию

Код вызова JavaScript функции в C# выглядит следующим образом:

public object CallJS(string functionName, object[] arguments, bool async)
{
    var dict = new Dictionary<string, object>();
    dict["arguments"] = arguments;
    dict["functionName"] = functionName;
    dict["async"] = async;
    
    SetJsBuffer(dict);

    return webKitBrowser.Document.InvokeScriptMethod("JS_CALL_HANDLER");
}

private void SetJsBuffer(object data)
{
    string id = "cs-js-buffer";

    Element elem = null;
    try
    {
        elem = webKitBrowser.Document.GetElementById(id);
    }
    catch (Exception) { } // получим исключение, если элемент не найден

    if (elem == null)
    {
        elem = webKitBrowser.Document.CreateElement("div");
        elem.SetAttribute("id", id);
        webKitBrowser.Document.GetElementsByTagName("body")[0].AppendChild(elem);
    }

    elem.TextContent = Newtonsoft.Json.JsonConvert.SerializeObject(data);
}

А в JavaScript вызов обрабатывается так:

JS_CALL_HANDLER = function() {
    // достаем и десериализуем данные из заданного div
    var dataFromCSharp = getDataFromDOM("cs-js-buffer");

    if (!dataFromCSharp) {
        return;
    }

    if (!dataFromCSharp.async) {
        return window[dataFromCSharp.functionName].apply(
            window,
            dataFromCSharp.arguments
        );
    } else {
        Ext.Function.defer(function () {
            window[dataFromCSharp.functionName].apply(
                window,
                dataFromCSharp.arguments
            );
        }, 1);
    }
}

Чтобы сделать вызов асинхронным мы просто вызываем указанную функцию с
задержкой на 1мс. Так как мы не нуждаемся в асинхронных вызовах, возвращающих значение,
то такое решение нас вполне удовлетворяет.

Вызовы C# из JavaScript

Штатного механизма вызовов С# из JavaScript в WebKit .NET в принципе нет. После пристального вглядывания в документацию было найдено событие WebKitBrowser.DocumentTitleChanged. Это, пожалуй, единственное событие, которое JavaScript легко может генерировать в любой момент путем установки document.title.

Есть две вещи, которые делают это событие привлекательным:

  • document.title можно установить достаточно большим, более 16Кб
  • установка document.title в JavaScript завершается только после выполнения всех обработчиков события DocumentTitleChanged в C#

Это легло в основу нашего протокола вызовов C# из JavaScript.
В следующем листинге приведен JavaScript код, предназначенный для вызовов C#.

var callMap = {}; // для хранения callback функций

/**
 * If call is synchronous, function returns response, received from c#,
 * otherwise - response data will be passed to callback.
 *
 * @param {Object}      config              Call properties
 * @param {String}      config.name         Name of C# function to call
 * @param {Object[]}    config.arguments    Array of arguments, that will be passed to C# function
 * @param {Boolean}     config.async        True, if this request must be performed asynchronously, 
 *                                              in this case callback and scope must be specified
 * @param {Function}    config.callback     Callback function
 * @param {Object}      config.scope        Scope for callback
 */
callCSharp = function(config) {
    var id = generateId(); // уникальный идентификатор вызова

    var callObject = {
        id          : id,
        name        : config.name,
        arguments   : config.arguments,
        async       : config.async
    };

    if (config.async) {
        callObject.callbackHandler = "COMMON_ASYNC_HANDLER";

        callMap[id] = {
            callback: config.callback,
            scope   : config.scope
        };
    }

    // invoke C# by triggering WebKitBrowser.DocumentTitleChanged event
    document.title = Ext.encode(callObject); // elegant, isn't it!
    // execution continues only after all C# handlers will finish

    if (!config.async) {
        var csharpResponse = getDataFromDOM(id);
        return csharpResponse.response;
    }
}

Как видно из листинга, каждый вызов снабжается уникальным идентификатором.
Далее он используется в качестве идентификатора элемента div, в который C# помещает результат.

Если вызов синхронный, то непосредственно перед своим завершением C# обработчик может
поместить свой результат в тело HTML документа. В этом случае метод callCSharp может
извлечь результат из dom сразу после установки document.title.

Для асинхронных вызовов C# должен инициировать запуск callback функций по завершении
обработчиков. В этом случае из C# мы вызываем специальный JavaScript метод, приведенный в следующем листинге:

/**
 * Handler for all C# async requests.
 * It calls real callback according to passed id.
 *
 * C# provides JSON of special format in div with passed id:
 * {
 *     response : {...},
 *     success  : true or false
 * }
 *
 * response and success are passed to callback function
 * @param {String} id Id of C# call
 */
COMMON_ASYNC_HANDLER = function(id) {
    var dataFromCSharp = getDataFromDOM(id);
    var callbackParams = callMap[id];

    delete callMap[id];

    callbackParams.callback.apply(callbackParams.scope, [
        dataFromCSharp.response,
        dataFromCSharp.success
    ]);
}

На стороне C# у нас есть класс CallManager, управляющий подпиской на вызовы из JavaScript. CallManager имеет единственный обработчик события WebKitBrowser.DocumentTitleChanged, который десериализует значение свойства WebKitBrowser.DocumentTitle и, в зависимости от указанного в JavaScript имени (config.name), вызывает соответствующий зарегистрированный обработчик с переданными параметрами. Также CallManager учитывает тип вызова: синхронный или асинхронный. В зависимости от типа обработчик вызывается либо синхронно, либо асинхронно.

Веб инспектор

Мы производим разработку интерфейса в два этапа. На первом этапе мы разрабатываем его как традиционное веб приложение, используя браузер и веб-сервер. Только по достижении желаемого результата мы переходим ко второму этапу — упаковке скриптов и интеграции с C#.

На первом этапе мы активно использовали инструменты разработчика в браузере и имели полный контроль над dom и JavaScript. Но после перехода ко второму этапу весь интерфейс попросту превращался в черный ящик. Возникающие проблемы с версткой и JavaScript становилось достаточно трудно обнаруживать. Мы вынуждены были искать хоть какой-то аналог веб инспектора.

К сожалению, WebKit .NET не позволяет использовать родной веб инспектор WebKit и никакой remote debugging тут не поддерживается. Поэтому мы решили воспользоваться Firebug Lite (1.4.0) — встраиваемым отладчиком, основанным на Firebug.

В HTML страницу Firebug Lite мы подключаем следующим образом:

<!DOCTYPE html>
<html>
<head>
    ...

    <script type='text/javascript' src='/path/to/firebug-lite.js'>
    {
        overrideConsole: true,
        startInNewWindow: true,
        startOpened: true,
        enableTrace: true
    }
    </script>

    ...
</head>
<body>...</body>
</html>

При работе со встраиваемым браузером удобнее, когда Firebug Lite открывается именно в новом окне, что задается опцией startInNewWindow. Но для того, чтобы это произошло необходимы некоторые манипуляции в C#:

browser.Url = new Uri(
    "http://localhost/path/to/debug-version-of-interface.html",
    System.UriKind.Absolute);
    
browser.NewWindowCreated += new NewWindowCreatedEventHandler(
(sender, newWindowEventArgs) =>
{
    // create new form with single item - Firebug Lite window
    debuggerForm = new DebuggerForm(newWindowEventArgs.WebKitBrowser);
    debuggerForm.Show();
});

Конечно, Firebug Lite не поддерживает отладку скриптов, но дает возможность исследовать dom и дает нам JavaScript консоль, а это уже облегчает разработку.

Заключение

После всех описанных выше доработок WebKit .NET превратился во вполне пригодный встраиваемый браузер, который стабильно работает и справляется с достаточно большим dom.

Конечно, реализация интерфейса таким образом сопряжена с определенными сложностями, которые по большей части вызваны отсутствием полноценного веб инспектора, но есть и плюсы. Например, можно повторно использовать JavaScript код из других частей приложения или вообще реализовать одинаковый интерфейс в мобильном, десктоп и веб приложении. Поэтому усилия можно считать оправданными.

Автор: amenshov

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js