Введение
Связка 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 код из других частей приложения или вообще реализовать одинаковый интерфейс в мобильном, десктоп и веб приложении. Поэтому усилия можно считать оправданными.