Сообщений 60    Оценка 1938 [+1/-1]         Оценить  
Система Orphus

Исследование WinForms 2.0 (beta 2)

Новые контролы и новые функции

Автор: Щербунов Нейл
Источник: RSDN Magazine #4-2005
Опубликовано: 07.04.2006
Исправлено: 13.04.2006
Версия текста: 1.1
Предисловие
AutoComрlete
Как это выглядит
На что обратить внимание
ToolTip
Как это выглядит
На что обратить внимание
SystemInformation
ToolStrip
Архитектура ToolStrip
ToolStripControlHost
Отрисовка ToolStrip
Как это выглядит
На что обратить внимание
MaskedTextBox
Обработка некорректного ввода
Как это выглядит
На что обратить внимание
SplitContainer
Как это выглядит
На что обратить внимание
FlowLayoutPanel и TableLayoutPanel
FlowLayoutPanel
Как это выглядит
TableLayoutPanel
Как это выглядит
На что обратить внимание
BackgroundWorker Object
Постановка задачи и общая архитектура решения
Как это выглядит
На что обратить внимание
WebBrowser
Как это выглядит
На что обратить внимание
ClickOnce – новая технология развертывания приложений
Как это выглядит
На что обратить внимание
Заключение

Код ко всем разделам, кроме ClickOnce

Код к разделу ClickOnce

Предисловие

Данная статья, являясь самостоятельным и законченным документом, в тоже время является и качественным развитием статьи опубликованной год назад: Новое в WinForms 2.0. Автор этих строк постарался “копать” не столько вширь, сколько вглубь. Поэтому новые элементы управления (далее – control-ы) и возможности исследованы достаточно детально и по каждому разделу имеется довольно серьезная demo-winform, запустив которую легко наглядно увидеть “а как это оно на самом деле”. Так же упомянуты пара-тройка особенностей, появившихся только в beta2 и отсутствующих в ранних версиях. Статья нацелена на аудиторию, имеющую твердый опыт работы с WinForms 1.0/1.1 и желающих сделать “быстрый старт” по направлению к WinForms 2.0. Итак,… к делу.

AutoComрlete

Так называется новая (и весьма повышающая продуктивность работы пользователя) функциональность control-ов ComboBox и TextBox. Далее по тексту (а также в демо-примере) упоминаются только элементы TextBox, которые с легкостью могут быть заменены комбобоксами с полным сохранением истинности всех предложений этого раздела, за исключением одного момента, отмеченного особо. Данная функциональность требует “плотной” работы с двумя (а в одном случае и с тремя) свойствами control-а TextBox. Рассмотрим их по порядку:

AutoCompleteMode

Данное свойство может принимать одно из четырех значений перечисления AutoCompleteMode и говорит, КАК мы будем помогать пользователю побыстрее закончить ввод:

NoneВообще помогать не будем, пусть ручками поработает :)
AppendПосле ввода нескольких первых символов, когда уже есть возможность предоставить выбор из разумного числа вариантов, первый такой вариант появляется полностью, но символы, не введенные пользователем, выделены. С этого момента пользователь входит в режим “виртуального” выпадающего списка. Виртуальный он потому, что физически его на экране нет, но перемещаться по его строчкам можно – циклически перебирать варианты можно с помощью стрелок вверх/вниз. По мере набора (физически) дополнительных символов в поле ввода этот "виртуальный” выпадающий список cжимается, оставляя все меньше и меньше вариантов для выбора.
SuggestПохоже на предыдущий вариант, но подсветки вообще никакой нет, а выпадающий список вполне реален. Т.е. это настоящий ComboBox. Пользователь видит все подходящие варианты и может по ним перемещаться. По мере набора дополнительных символов этот выпадающий список также ужимается.
SuggestAppendКак и следует из названия – логическое И предыдущих вариантов: подсветка И реальный выпадающий список. Вариант на любителя. :)
Таблица 1.

AutoCompleteSource

Данное свойство может принимать одно из девяти значений перечисления AutoCompleteSource и говорит, чем мы будем помогать пользователю побыстрее закончить ввод:

NoneАналогично одноименному значению свойства AutoCompleteMode. Ручками, ручками… все ручками. :)
HistoryListПредлагаются Uniform Resource Locators (URLs) из общесистемного списка истории посещений. К примеру, на моей машине после ввода “rs” мне предложили:
http://rsdn.ru
http://www.rsdn.ru/article/authors/template.xml
http://rsdn.ru/article/authors/requirements.xml
.... и т.д.
RecentlyUsedListТе же URL, но из списка наиболее недавно использованных. Причем это не обязательно только адрес Web-странички. Опять же в моем случае после ввода “r” я получил на выбор:
Regedit
regedt32.exe
http://rastaman.tales.ru.... и т.д.А ввод “s” дал лишь один вариант - sfc /scannow
AllUrlОбъединение предыдущих двух значений, т.е. все варианты из HistoryList + все варианты из RecentlyUsedList
FileSystemПредлагаются файлы и папки текущей системы. К примеру, начнем набирать “c:\wi” и вуаля! У нас уже написано “c:\windows”. А “c:\b” предложит “c:\boot.ini”, “c:\bootfont.bin” и т.д.
FileSystemDirectoriesТо же, что и предыдущий вариант, но только папки, без файлов.
AllSystemSourcesВсе варианты из AllUrl + все варианты из FileSystem
ListItemsТот самый "момент”, где рассматриваемая функциональность control-ов ComboBox и TextBox не совпадает. Данное значение применимо только к ComboBox. В качестве вариантов для автозавершения рассматривается содержимое коллекции ComboBox.ObjectCollection. Полагаю это будет одним из самых популярных значений для именно комбобоксов, т.к. оно крайне органично вплетается в суть этого контрола вообще – быстро выбрать из ограниченного числа вариантов.
CustomSourceИсточник вариантов определяется абсолютно произвольно. Единственный случай – когда требуется применение 3-го свойства AutoCompleteCustomSource. О нем – ниже.
Таблица 2

AutoCompleteCustomSource

Данное свойство вступает в игру только если значение свойства AutoCompleteSource равно CustomSource. В этом случае необходим массив строк, который следует поместить в коллекцию AutoCompleteStringCollection, а затем "скормить” заполненную коллекцию рассматриваемому свойству. Каждая строка являет собой потенциальный вариант выбора пользователя.

Как это выглядит

Демо-пример данного раздела запускается кнопкой "Textboxes and automatic completion" в главной форме. В новой форме представлено восемь TextBox-ов (по числу значений AutoCompleteSource, за вычетом ListItems – он для TextBox-а не годится) и один многострочный TextBox для "игр" с вариантом CustomSource – он служит "инкубатором" для возможных вариантов автозавершения. Радиокнопки позволяют посмотреть варианты работы свойства AutoCompleteMode, причем это свойство переключается сразу для всех восьми тестируемых TextBox-ов:


Рисунок 1.

На что обратить внимание

Если вы еще не запускали демонстрационный пример, описанный в предыдущем разделе, сделайте это сейчас. А если попробовали – закомментируйте атрибут [STAThread] в точке входа и попробуйте еще раз, после перекомпиляции, разумеется. Несомненно, вы обратите внимание, что в последнем случае от функциональности AutoComplete не осталось и следа. Это несомненный признак того, что наш старый "друг" COM interop в данном случае задействован по полной программе. Так что не относитесь к сгенерированному Visual Studio коду как к ненужной избыточности – практически каждая строчка имеет важное значение в том или ином случае. Изменять сгенерированный код можно только если вы абсолютно уверены в своих действиях. Иначе есть риск потратить несколько часов на решение "идиотского" вопроса – почему не работает то, что обязано работать по определению, как это и случилось со мной, когда я написал первую версию упомянутого демо-примера.

Второй момент, на который стоит обратить внимание – это честное предупреждение документации о возможном ограничении (самой OS) числа одновременно показываемых пользовательских строк, они же пользовательские варианты. Скажу честно – за время экспериментов я с таким ограничением не сталкивался, но рассуждения общего плана подталкивают меня к такому видению этого момента: если AutoCompleteStringCollection (источник пользовательских вариантов) содержит 20 тысяч (цифра условна) строк, начинающихся на букву “w”, то после ввода пользователем этой буквы "вываливать" ему все 20 тысяч вариантов нет ни малейшего резона – явная трата ресурсов без какой-либо видимой пользы, никто не будет вручную выбирать из такого количества вариантов. Поэтому ОС вполне разумно покажет лишь небольшую часть вариантов (как намек) и подождет ввода еще одной-двух букв, чтобы показать уже более-менее вменяемый список. Одним словом, как и в любом хорошем деле, в случае AutoComplete необходимо соблюдения золотого принципа разумной достаточности. Иначе это сделают за нас!

ToolTip

В данном control-е сделан ряд серьезных изменений по сравнению с версией 1.0/1.1. Добавились такие интересные свойства, как ToolTip.BackColor и ToolTip.ForeColor, позволяющие менять цвет фона и текста всплывающей подсказки. ToolTip.ToolTipTitle указывает заголовок подсказки – текст, отображаемый над основным содержимым подсказки жирным шрифтом. Свойство ToolTip.ToolTipIcon позволяет "пририсовать" к подсказке одну из трех маленьких иконок (ошибка/предупреждение/информация), либо указать, что иконка не требуется. Более серьезное свойство, кардинально меняющее внешний вид нашей подсказки – ToolTip.IsBalloon. И наконец, ToolTip.OwnerDraw, установленное в true, позволит рисовать что угодно на всей площади всплывающей подсказки. Это свойство работает в плотной связке с событием ToolTip.Draw, в обработчике которого, собственно, и идет пользовательская отрисовка подсказки.

Как это выглядит

Демо-пример данного раздела запускается кнопкой "ToolTip control" в главной форме. В новой форме представлены ряд control-ов для тонкого "тюнинга" внешнего вида подсказки и тестовая площадка (обычный Label), где этот внешний вид и проявляется во всей красе. :) Представлены все свойства из предыдущего абзаца и 3 свойства "времени" подсказки. Последние не являются новыми для версии 2.0, но добавлены в демо-пример "для комплекта". Все вместе это выглядит так:


Рисунок 2.

На что обратить внимание

Первый "скользкий" момент связан с новым видом подсказки – balloon. Дело в том, что в Windows XP есть возможность отключить показ таких подсказок на уровне самой системы. Отвечает за это параметр реестра EnableBalloonTips (тип DWORD): HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced. Так вот, если пользователь выставил этот параметр в 0, а вы приравняете ToolTip.IsBalloon true, то такой пользователь не увидит никакой подсказки – ни balloon, ни обычной прямоугольной. В своем демо-примере я решаю этот вопрос путем проверки указанного параметра:

if ((int)Registry.GetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows"
   @"\CurrentVersion\Explorer\Advanced", "EnableBalloonTips", 1) == 0)
  // отказать в установке внешнего вида типа balloon
else
  this._tT.IsBalloon = true;

Вы можете придумать свой "обходной маневр". Главное – помнить, что такая тонкость присутствует, по крайней мере в Windows XP.

Второй, еще более "скользкий", момент связан (по крайней мере я искренне надеюсь на это!) с текущей стадией продукта – бета, как ни крути. А момент такой: если вы "зажгли" подсказку и дали ей погаснуть самой (мышь не дергали, просто вышло время из AutoPopDelay) то в текущей сессии вы эту подсказку больше не увидите. : ( Явно коррелирующая с предыдущей проблема – даже если время не вышло, но вы кликнули по control-у, с которым ассоциирован ваш ToolTip, то аналогично можете сказать goodbye подсказке для текущей сессии. MS внимательно изучил этот баг и признал его как факт. И вот официальный ответ:

Microsoft on 2005-03-29 at 21:28:12: Решение – отложить. К сожалению, мы не сможем исправить этот баг в релизе Whidbey. Мы оставим это для будущих версий.

Очень надеюсь, что под Whidbey имеется в виду именно бета-стадия продукта, а не весь VS2005+FW2.0, включая и релиз. Иначе ценность ТАКОГО ToolTip-а падает процентов на 80…

ПРИМЕЧАНИЕ

Эта ошибка в окончательной версии .Net Framework 2.0 действительно есть, так что надежды не сбылись. – прим.ред.

SystemInformation

В данный класс добавилось множество свойств, нацеленных, прежде всего, на информирование о внешнем виде всевозможных "рюшечек" в Windows XP. Запустив демо-пример кнопкой "SystemInformation class" в главной форме, можно увидеть все эти новые (а так же и перекочевавшие из версии 1.0/1.1) свойства. Будут показаны: имя свойства, его описание, тип возвращаемого значения, описание возвращаемого значения – и, наконец, само значение. Ничего сногсшибательного, но достаточно занятно и иногда практично. Поскольку описания свойств и возвращаемых ими значений достаточно длинны и, как правило, не вмещаются в отводимые им колонки, вся информация, показываемая в демо-примере, дублируется в виде всплывающих подсказок. Поэтому не спешите судорожно менять размеры колонок, чтобы прочесть полное описание. Лучше просто подождите полсекунды. :) Разумеется, курсор мыши должен быть позиционирован на интересующем вас участке ListView. Никаких проблем с работой этого класса (SystemInformation) я не заметил – все свойства работают и возвращают вполне "вменяемые" значения. Рекомендую к применению. :)


Рисунок 3.

ToolStrip

В версии фреймворка 1.0/1.1 мы имели дело с двумя фактически независимыми компонентами – MainMenu и ContextMenu, а также с двумя уж совсем никак не связанными control-ами – ToolBar и StatusBar. Чтобы положить конец подобному "разброду и шатанию", был изобретен… нет, не новый универсальный control. И даже не набор усовершенствованных control-ов. Была изобретена целая технология, получившая название (по крайней мере на текущий момент, но вряд ли ее название поменяется) ToolStrip Technology. И столь громкий статус присвоен ей заслуженно. Это действительно большая самостоятельная подсистема в рамках WinForms. Безусловно она заменяет упомянутые ранее компоненты/control-ы. Но только ради замены не стоило, наверно, затевать столь масштабную перестройку. И действительно – новая технология предлагает ряд "вкусностей", мечтать о которых ранее не приходилось:

Давайте попробуем разобраться во всей этой роскоши….

Архитектура ToolStrip

В самом общем виде архитектура новой технологии, а также стиль работы с ней, будут выглядеть так: за основу берется экземпляр класса ToolStripContainer. Это будет контейнер, который примет в себя все меню, инструментальные панели и статусные строки, а также прочие control-ы, не имеющие отношения к рассматриваемой технологии, вроде обычного RichTextBox. В design-time мы просто бросаем экземпляр этого класса на форму. Причем чаще всего мы займем им всю доступную площадь формы:

this.toolStripContainer1.Dock = System.Windows.Forms.DockStyle.Fill;

хотя, разумеется, это совсем не жесткое требование. Вот что мы увидим в дизайнере (здесь, для наглядности, ToolStripContainer как раз НЕ занимает всю форму):


Рисунок 4.

Итак, с четырех сторон ToolStripContainer окружен элементами ToolStripPanel. Это именно те самые "отсеки", готовые принять в себя тулбары и меню. Верхняя и левая панель на рисунке распахнуты, две другие – свернуты. "Умный ярлык" (smart tag) контейнера нажат и позволяет увидеть самые характерные задачи, которые чаще всего придется выполнять разработчикам при работе с ToolStripContainer-ом: отключить/включить любую из четырех панелей или сделать ту нехитрую операцию, о которой шла речь – заполнить контейнером всю форму (Dock Fill in Form). По центру находится ToolStripContentPanel – наиобычнейшая панель, готовая принять абсолютно любой control, хотя бы тот же RichTextBox. Однако нас, в рамках данного раздела, интересует не она, а четыре ToolStripPanel по краям контейнера. Именно они станут "пристанищем" для контейнеров следующего уровня – ToolStrip, MenuStrip, StatusStrip. Их предназначение очевидно из названий: первый станет "зародышем" будущего полноценного тулбара, второй – меню (Main, не контекстного!), третий – статусбара. Почему я назвал их "контейнерами следующего уровня"? Потому, что и они в свою очередь станут "домом" для целого ряда control-ов, произведенных от класса ToolStripItem. Вот что можно поместить в них:

Название элемента, производного от ToolStripItemНазначение элементаРекомендуется к применению в ToolStrip?Рекомендуется к применению в MenuStrip?Рекомендуется к применению в StatusStrip?
ToolStripLabelНе-выбираемый элемент, способный отобразить текст, картинку, гиперссылкуYesNoYes
ToolStripDropDownButtonСпециальная кнопка. При нажатии открывает связанный с ней control ToolStripDropDown, из которого, в свою очередь, пользователь может выбрать элемент, производный от ToolStripItem.YesNoYes
ToolStripButtonОбычная кнопка. На нее можно поместить как текст, так и изображение, или и то и другое.YesNoNo
ToolStripSplitButtonКомбинация стандартной кнопки слева и drop-down кнопки справа от нееYesNoYes
ToolStripSeparatorРазделитель, помогающий формировать визуальные группы из ToolStripItem-элементовYesYesNo
ToolStripTextBoxОбычное окно для ввода текстаYesYesNo
ToolStripComboBoxОбычный комбинированный список для ввода текста либо для выбора предопределенного элемента из спискаYesYesNo
ToolStripMenuItemСпециальный элемент, предназначенный для контейнеров типа MenuStrip или ContextMenuStripNoYesNo
ToolStripStatusLabelПредставляет собой индивидуальную панель в контейнере типа StatusStripNoNoYes
ToolStripProgressBarОбычный прогресс-бар, но предназначенный для контейнера типа StatusStripYesNoYes
ToolStripControlHostЭтот элемент может принимать любой WindowsForm control, которому вы желаете придать ToolStrip-функциональность.YesYesYes
Таблица 3.

Что обозначают колонки "Рекомендован…"? Дело в том, что чисто технически любой из упомянутых суб-контейнеров может принять абсолютно любой элемент, унаследованный от ToolStripItem. Тем не менее, согласимся, что комбобокс на статус-баре может повергнуть среднего пользователя в ступор. Вот во избежание тяжких душевных потрясений MS и подготовил такую рекомендацию из серии "кесарю – кесарево". И, должен признать, эта система выглядит вполне стройно и, опять же, полностью выведена из опыта разработки Office-like приложений. Лучше ее придерживаться. Итак, следующий шаг – размещение суб-контейнеров по соответствующим ToolStripPanel:


Рисунок 5.

На рисунке 5 в верхнюю панель добавлены MenuStrip и ToolStrip, еще один ToolStrip – в левую и один StatusStrip – в нижнюю. Хочу обратить внимание на то, что в коде такое встраивание выглядит весьма ожидаемо:

this.toolStripContainer1.TopToolStripPanel.Controls.Add(this.toolStrip1);

Т.е. достаточно указать интересующую панель (Top для строчки выше), получить ее коллекцию control-ов и добавить в нее желаемый ToolStrip (а равно и MenuStrip, и StatusStrip). Именно так действует кодогенератор VS. Аналогичный подход можно применять и при добавлении ToolStrip-ов динамически, во время исполнения. Но я крайне рекомендую вместо этого пользоваться методом ToolStripPanel.Join (). Он имеет четыре перегруженных варианта и позволяет гораздо точнее позиционировать "динамический" ToolStrip по отношению к другим control-ам подобного рода, размещенным в той же панели. В частности, если уже есть один ToolStrip в верхней панели, а во время исполнения нужно добавить еще один, данный метод позволит без труда сдвинуть существующий ToolStrip вправо, и "динамический" экземпляр появится левее его. Стандартный ...Controls.Add (...) подобной гибкостью не обладает.

У верхнего ToolStrip-а открыт "Умный ярлык", чтобы продемонстрировать наиболее характерные задачи, выполняемые в design-time с этим объектом. Пожалуй, самый интересный пункт в этом списке – Insert Standard Items. Он позволяет вставить самые популярные кнопки вроде New/Open/Save/Print и Cut/Copy/Paste и визуально сгруппировать их. На скриншоте выше я не стал пользоваться этим пунктом, а просто кинул на верхний ToolStrip пару ToolStripLabel, пару ToolStripButton, и по штучке ToolStripSeparator и ToolStripSplitButton. Остальные суб-контейнеры также не остались обделенными соответствующими ToolStripItem-элементами. Если теперь скомпилировать и запустить этот пример, становится ясной вся прелесть ToolStripPanel. Эта панель не только позволяет докировать Strip-контейнеры (что совсем не удивительно), но и предлагает новую возможность, называемую рафтингом (rafting). Суть его в том, что когда пользователь во время исполнения "притаскивает" в такую панель новый Strip-контейнер (например, из другой панели), ему автоматически находится место среди прочих ToolStrip/MenuStrip/StatusStrip control-ов. Иными словами, рафтинг – это способность упомянутых control-ов разделять вертикальное/горизонтальное пространство с другими подобными control-ами. К примеру, "взыскательный пользователь" мог бы перекроить предложенный ему GUI с рисунка 5 так, как показано на рисунке 6.


Рисунок 6.

Обратите внимание, что изменилось не только местоположение самих контейнеров. Элементы внутри контейнеров также поменялись местами, если сравнить их положение с design-time вариантом. Замечу (может для кого-то это будет внове), что контейнер перемещается просто путем захвата мышкой его "рукоятки" (grip, точечная полоска слева/вверху), а элементы внутри контейнера перемещаются при зажатой клавише ALT. При этом можно переместить даже non-selectable элементы, как я сделал, к примеру, с ToolStripProgressBar и ToolStripStatusLabel лежащих на статус-баре. Сразу же отвечу на волнующий многих вопрос – а можно ли такую "анархию" запретить? Да, без проблем. Чтобы воспрепятствовать перетаскиванию самих контейнеров, выставьте свойство контейнера GripStyle в ToolStripGripStyle.Hidden. Это приведет к пропаданию "рукоятки", и схватиться будет элементарно не за что. А для запрета перемещения элементов внутри контейнера задайте свойству AllowItemReorder значение false. Но влезем на секундочку в шкуру подобного "взыскательного пользователя» (разработчикам вообще не повредит такая смена угла зрения на разрабатываемую программу). Вот мы расположили элементы ToolStrip-ов так, как нам удобно. Чудно. НО! Ведь при следующем запуске приложения вся эта красота пропадает, и мы оказываемся опять с тем дизайном UI, который удобен с точки зрения разработчика, но совсем не с нашей, пользовательской точки зрения. Так что же, начинать каждую сессию с настройки интерфейса? Ответ на этот вопрос зависит от предусмотрительности разработчика и его осведомленности о том, что в классе ToolStripManager есть встроенная функциональность для элегантного решения подобного вопроса. Вообще, упомянутый класс - это "класс-многостаночник", управляющий в рамках ToolStrip Technology рядом напрямую не связанных задач. В частности, именно этот класс определяет (или, точнее, МОЖЕТ определять), как будут визуально выглядеть ToolStrip-контейнеры и элементы. Об этом речь пойдет ниже, в разделе “ToolStrip Rendering”. Но в данном случае куда интереснее его способность сохранять дизайн UI, сотворенный пользователем, и восстанавливать его в следующей сессии. Реализуется эта способность через два статических метода: SaveSettings () и LoadSettings (). Разберем их работу на примере. Допустим, мы предоставляем пользователю (например, через контекстное меню) опцию "Сохранить рабочую область…". После создания удовлетворяющего его дизайна пользователь вызывает эту опцию, и в появившемся окне вводит название сохраняемых настроек, например, "PowerUserNo1". Остается задать лишь одну строчку кода:

ToolStripManager.SaveSettings(this, "PowerUserNo1");

Все, пользовательские настройки UI сохранены! Причем не только местоположения ToolStrip-ов на форме, но и расположение отдельных ToolStrip-элементов в пределах каждого ToolStrip-а и т.д.

Чисто технически выполнение этой строчки вызовет следующее: в директории Documents and Settings\LocalService\Local Settings\Application Data\ будет создана (если ее еще нет) новая поддиректория с именем организации (!) на которую была зарегистрирована данная копия Windows в момент ее установки. Да, вот так вот. Далее в этой, возможно, новой поддиректории будет создана (если ее еще нет) новая поддиректория с именем исполняемого файла + специально рассчитанный хэш. В ней, опять же при необходимости, создается еще одна с именем, равным версии нашего приложения. И уже там (у-ффф… последней, наконец-то!) создается скромный файлик user.config, который и хранит настройки UI для ВСЕХ пользователей приложения. В результате запуска файлf _11_.vshost.exe (отладочная версия из-под VS) была создана такая структура директорий (“Piv” = имя организации)/:

c:\Documents and Settings\LocalService\Local Settings\Application Data\Piv\_11_.vshost.exe_StrongName_xdf0lknv0ddah4tn0n4kh33eyxd3ije3\1.0.0.0\user.config

Таким образом, все настройки от всех пользователей хранятся в одном файле. Этот файл является форматированным XML-документом и может быть считан кем угодно, понимающим этот формат. Для:

будет сформирован свой набор XML-тегов, описывающих желаемый дизайн UI. Как же воспользоваться сохраненными настройками? Элементарно! В тот момент, когда точно известно, какой дизайн требуется текущему пользователю (допустим, при старте приложения он выбрал профиль "PowerUserNo1"), исполняется такой код:

ToolStripManager.LoadSettings(this, "PowerUserNo1");

Все! Интерфейс точно в том же состоянии, что и на момент вызова метода SaveSettings. Разумеется, второй параметр метода варьируется по необходимости.

Как показано в таблице 2, технология ToolStrip предлагает немало ToolStrip-элементов. Разбор работы каждого такого элемента персонально грозил бы переходом этой статьи в категорию полноценной книги, поэтому я сосредоточусь на единственном (но самом необычном) элементе – ToolStripControlHost.

ToolStripControlHost

Суть проблемы заключается в том, что с точки зрения ToolStrip Technology нормальный WinForms control является все же "недоконтролом", который нельзя использовать в рамках данной технологии. Так что же – персонально под ToolStrip надо переписать заново код для всех элементов управления? Это ж адский труд! Видимо, в MS подумали то же самое, и нашли изящный выход из ситуации. Был создан специальный элемент ToolStripControlHost, унаследованный все от того же ToolStripItem и придающий любому control-у WinForms ToolStrip-функциональность. Собственно, упомянутые выше ToolStripComboBox, ToolStripTextBox и ToolStripProgressBar представляют собой ни что иное, как обычные WinForms-control-ы, "обернутые" ToolStripControlHost. Конечно можно было бы удобства ради обернуть подобным образом каждый control, но сборка System.Windows.Forms стала бы раза в полтора "толще", а обоснованность такого решения "на опережение" была бы под сильным вопросом. Поэтому было принято решение, при котором и овцы целы, и разработчики относительно сыты – а именно: разработчик сам выбирает control, который нужен в данной ситуации, и сам обертывает его ToolStripControlHost-ом. Тут же проявляется еще один плюс: подобный подход применим не только к родным WinForms-control-ам, но и к пользовательским. Причем техника работы в последнем случае точно такая же. Как же все это происходит? Достаточно несложно. Допустим, нужно поместить на инструментальную панель нечто вроде закладок таб-control-а. Поскольку это будет control, подобный, но отличный от стандартного System.Windows.Forms.TabControl, придумаем ему новое название – например, ToolStripTabControl. Теперь можно оформить его как наследника ToolStripControlHost:

public class ToolStripTabControl : ToolStripControlHost { ... }

Теперь в конструкторе по умолчанию нашего control-а вызываем конструктор базового класса, передавая ему в качестве параметра экземпляр "расширяемого" control-а, в данном случае это System.Windows.Forms.TabControl:

public ToolStripTabControl() : base(new TabControl()) { }

Чтобы пользователи нового класса ToolStripTabControl могли работать с "внедренным" таб-control-ом, предоставим доступ к нему через get-only свойство:

public TabControl TabControl
{
  get { return(TabControl)base.Control; }
}

И, в самом общем случае, это все – можно подавать на стол. :) Применение нового control-а – самое незатейливое. Весь код помещается в отдельный файл проекта, к примеру, в ToolStripTabControl.cs. В коде создается экземпляр control-а, с которым можно работать как с самым обычным System.Windows.Forms.TabControl. Я добавляю ему 3 закладки с именами Page_1/2/3, и для иллюстрации подписываюсь на событие TabControl.SelectedIndexChanged:

public partial class Form1 : Form
{
  private ToolStripTabControl _tsTabControl = new ToolStripTabControl();
  public Form1()
  {
    InitializeComponent();
    this._tsTabControl.TabControl.TabPages.Add("Page_1");
    this._tsTabControl.TabControl.TabPages.Add("Page_2");
    this._tsTabControl.TabControl.TabPages.Add("Page_3");
    this._tsTabControl.TabControl.SelectedIndexChanged += 
      new EventHandler(TabControl_SelectedIndexChanged);
    this.toolStrip2.Items.Insert(2, this._tsTabControl);
  }
.......................
}

Последняя строчка приведенного кода требует определенных пояснений. Дело в том, что с пользовательским ToolStrip control-ом нельзя работать в дизайн-режиме. : ( Увы-увы, но это так. Дизайнер совершенно не осведомлен, что, оказывается, появился новый control, подходящий для ToolStrip-технологии. Правда, подобная поддержка, вроде, планируется… Вот что сказал член команды VS2005 на официальном форуме MS:

ПРИМЕЧАНИЕ

Да, мы планируем сделать это в Whidbey – вы сможете выбирать пользовательский тип так же, как базовый (ToolStripButton и тому подобное).

В общем, вы понимаете – мечты, мечты… :) Пока мы вынуждены добавлять наш новый control в коллекцию просто методом Insert. По счастью, у него есть довольно удобный параметр – индекс в коллекции, куда будет вставлен наш control. Разумеется, визуально он отобразится именно на этом месте (самый левый/верхний элемент имеет индекс 0):


Рисунок 7.

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

Отрисовка ToolStrip

Технология ToolStrip использует схему отрисовки (rendering), которая разительно отличается от схемы, примененной в прочих control-ах WinForms. Это позволяет приложениям легко менять свои "шкурки", т.е. быть «skinable», как это стало модным в современных программах. Разница в ToolStrip-схеме отрисовки заключается в том, что при ее использовании не придется реагировать на событие Paint каждого ToolStripItem-а. Вместо этого применяется довольно изощренная и несколько нелогичная система "рисовальщиков" (Renderer). Идея вот в чем. Каждый ToolStrip-объект может рисоваться персонально, а может и в рамках единой концепции рисоваться вместе со всеми прочими ToolStrip-объектами. Таким образом, возможно создание очень хитрых схем, где часть объектов рисуется системой, часть рисуется единообразно (но отлично от предыдущих), а часть вообще ни на что не похожа. Получается этакий серо-буро-малиновый винегрет. :) Впрочем это все к дизайнерам пользовательского интерфейса… Нам, как разработчикам, требуется просто знать, как все это реализуется. Так вот, для реализации подобной схемы были задействованы два свойства двух классов. Первый класс – хорошо уже известный ToolStrip или любой производный от него (StatusStrip, MenuStrip и т.д.). Второй – ToolStripManager. Первый управляет рисованием КОНКРЕТНОГО ToolStrip объекта. Второй говорит, как будут рисоваться ВСЕ ToolStrip-объекты в рамках данного приложения. Упомянутые два свойства в обоих классах называются одинаково. Renderer возвращает/устанавливает объект который и будет выступать в роли того самого рисовальщика. Требование к нему – быть наследником абстрактного класса ToolStripRenderer. Второе свойство – RenderMode. Как ни странно прозвучит, его назначение то же самое, что и у предыдущего. Более того, присваивание некого значения первому свойству автоматом меняет и это. Так что его роль несколько вторична, но тем не менее важна. Итак, чему же может быть равно свойство RenderMode? Возможные варианты для обоих классов представлены в таблице 4.

ToolStripManager.RenderMode = ToolStripManagerRenderMode.ProfessionalВсе ToolStrip-объекты, для которых не указано иное, рисуются в соответствии с текущей цветовой схемой Windows. Т.е. control-ы "подстраиваются" под цветовые предпочтения данного пользователя.
ToolStrip.RenderMode = ToolStripManagerRenderMode.ProfessionalЭтот, конкретный ToolStrip-объект рисуется в соответствии с текущей цветовой схемой Windows. Т.е. этот control "подстраивается" под цветовые предпочтения данного пользователя.
ToolStripManager.RenderMode = ToolStripManagerRenderMode.SystemВсе ToolStrip-объекты, для которых не указано иное, рисуются в соответствии с системными цветами Windows и т.н. "плоским" стилем отображения.
ToolStrip.RenderMode = ToolStripManagerRenderMode.SystemЭтот конкретный ToolStrip-объект рисуется в соответствии с системными цветами Windows и т.н. "плоским" стилем отображения.
ToolStripManager.RenderMode = ToolStripManagerRenderMode.CustomНа самом деле присваивание, показанное слева, в этой строке невозможно. Нельзя явно указать ToolStripManager-у, что будет использован т.н. "пользовательский рисовальщик". Да это и не нужно. Вместо этого можно присвоить свойству ToolStripManager.Renderer значение, равному этому самому "пользовательскому рисовальщику". Другими словами, это последнее свойство НЕ должно быть равным ни ToolStripProfessionalRenderer, ни ToolStripSystemRenderer. В этом, и только в этом случае, свойству ToolStripManager.RenderMode будет автоматически присвоено значение Custom. Излишне повторять, что в этом случае "пользовательский рисовальщик" будет отображать все ToolStrip-объекты, для которых не указано иное.
ToolStrip.RenderMode = ToolStripManagerRenderMode.CustomАбсолютно аналогично предыдущей строке. Присвоение невозможно, но это значение будет возвращено, если ToolStrip.Renderer-у будет указано использовать НЕ ToolStripProfessionalRenderer и НЕ ToolStripSystemRenderer. И, разумеется, ManagerRenderMode-вариант (см. ниже) также не должен быть задействован. При соблюдении всех этих условий этот конкретный ToolStrip-объект рисуется тем "пользовательским рисовальщиком", что был указан в свойстве ToolStrip.Renderer.
ToolStrip.RenderMode = ToolStripManagerRenderMode.ManagerRenderModeВот тут – ключевой момент всей этой музыки. Везде, где выше появлялась фраза "…для которых не указано иное", она подразумевала именно тот факт, что свойству ToolStrip.RenderMode БЫЛО присвоено значение ManagerRenderMode. Если этот НЕ так, данный конкретный ToolStrip объект будет рисоваться либо "пользовательским рисовальщиком", либо рисовальщиком ToolStripProfessionalRenderer, либо рисовальщиком ToolStripSystemRenderer, причем не зависимо от того, что говорит ToolStripManager. Но если все же сделать присвоение, показанное слева, данный конкретный ToolStrip-объект будет полностью полагаться на настройки ToolStripManager и отрисовываться так, как скажет он. Именно это значение свойства ToolStrip.RenderMode позволяет оформить все объекты в едином концептуальном стиле.
Таблица 4.

Не лишним будет заметить, что оба описанных свойства первого класса являются экземплярными (ведь они говорят, как будет рисоваться этот, конкретный экземпляр), а второго - статическими (ведь они характерны для всего приложения, и нужны лишь в единственном экземпляре).

Теперь перейду к созданию самого изощренного, пользовательского отрисовщика. Подробный рассказ о такой задаче выходит за рамки данной статьи (и вполне может быть темой отдельной публикации), но общих принципов стоит коснемся. Класс рисовальщика нужно унаследовать от ToolStripRenderer:

class MyTextRenderer : System.Windows.Forms.ToolStripRenderer {...}

Если нужно особым образом рисовать текст на ToolStrip-е, необходимо перекрыть метод OnRenderItemText, к примеру:

protected override void OnRenderItemText(
  ToolStripItemTextRenderEventArgs e)
{
    e.TextColor = Color.MediumPurple;
    e.TextFont = new Font("Helvetica", 9, 
      FontStyle.Bold | FontStyle.Italic | FontStyle.Underline);
    base.OnRenderItemText(e);
}

Здесь ничего особо выдающегося не происходит – меняется шрифт и его цвет. Остальное делает базовый класс. Но, разумеется, ваша фантазия может развернуться как только пожелает. Как в любом деле – главное вовремя остановиться. :) Реализация этих возможностей заключается в перекрытии виртуальных методов базового класса с именами вида OnRenderXXX, где XXX – имена различных элементов ToolStrip-объекта, которые хотелось бы рисовать "по-особому". Например, я использовал XXX=ToolStripBorder (для рисования границы ToolStrip-а), XXX=ToolStripBackground (поверхность ToolStrip-а), XXX=SplitButtonBackground (объект справа от ToolStripSplitButton-а, открывающий drop-down меню; обычно отрисовывается в виде маленького черного треугольника, указывающего вниз). Далее весь этот класс можно оформить в виде отдельного файла в рамках текущего проекта, и указать конкретному ToolStrip-объекту или всему приложению использовать именно этот рисовальщик. Для иллюстрации ниже я выбрал первый вариант и измывался только над верхним ToolStrip-ом, сказав, что только он один будет отрисовываться по-особому. Конкретные строки кода приведены прямо на рисунке. Там же показан внешний вид верхнего ToolStrip-а при отрисовке его ToolStripProfessionalRenderer-ом, ToolStripSystemRenderer-ом, и, наконец, тем самым MyTextRenderer-ом о котором речь шла выше. Для сравнения приведены MenuStrip и левый ToolStrip (частично) которые во всех трех случаях рисовались ToolStripProfessionalRenderer, используемым по умолчанию:


Рисунок 8.

В "полновесном" демо-примере (см. следующий абзац) вы сможете выбрать – применить специальную отрисовку к верхнему ToolStrip-у или же ко всем объектам.

Как это выглядит

Чтобы запустить демо-пример к данному разделу, щелчкните по кнопке "ToolStrip Technology" в главной форме. Еще раз хочу подчеркнуть, что в примере (для наглядности), главный контейнер (ToolStripContainer) НЕ занимает всю площадь формы. В реальных приложениях такая раскладка будет встречаться нечасто – обычно под тулбар/меню/строку статуса отводят всю ширину главной формы. Итак, демо-пример позволит увидеть и попробовать, как программно можно показать/спрятать "рукоять" для перетаскивания ToolStrip объекта, прикрепить (не забывайте – в случае необходимости будет автоматически задействован рафтинг!) его на любую из четырех сторон ToolStripContainer, поместить на него специальный ToolStripTabControl, описанный чуть выше. Все эти операции доступны через главное меню. Помимо этого, к центральной части ToolStripContainer-а «прикреплено» контекстное меню с дополнительными опциями: отрисовка ToolStrip-объектов тремя различными рисовальщиками (в т.ч. и MyTextRenderer), причем верхний ToolStrip может рисоваться независимо от всех прочих объектов – у него свой пункт в контекстном меню. Кстати, обратите внимание, что само это меню является абсолютно полноправным членом ToolStrip Technology. Хотя при отрисовке (через ToolStripManager) всех объектов каким-то специальным рисовальщиком оно остается неизменным. Но это сделано предумышленно, чтобы ярче подчеркнуть возможности пользовательской отрисовки. Вполне можно оформить это меню в едином стиле со всеми тулбарами/главными меню. К примеру, если вы хотите посмотреть как оно будет выглядеть при отрисовке MyTextRenderer-ом, то всего лишь закомментируйте строку

this._contMenuStrip.RenderMode = ToolStripRenderMode.Professional;

и вызовите последовательность команд “Render ALL ToolStrips (via ToolStripManager) as...”->” Custom Renderer”. Более того, чтобы показать насколько тесно контекстные меню интегрированы в общую схему новой технологии, я добавил еще один пункт, позволяющий "вписать" ToolStripTabControl прямо в это меню. Результат представлен на рисунке 9. Не знаю, насколько жизненной может оказаться подобная конструкция (как минимум, она непривычна для пользователя), но возможность такая есть и реализуется весьма несложно.


Рисунок 9.

Два последних пункта контекстного меню позволяют сохранить/загрузить настройки интерфейса, создаваемые в процессе экспериментов. Причем можно сохранить даже две раскладки: одну под именем “PowerUser_1”, вторую под именем “PowerUser_2”.

На что обратить внимание

Будем откровенны – текущая реализация технологии хромает на обе ноги. К примеру, накидав в дизайнере три ToolStrip-а, и не написав ни строчки своего кода, я стабильно в run-time получал исключение ArgumentOutOfRangeException, при попытке несколько раз подряд задокировать эти ToolStrip-ы к различным краям контейнера. При той же нехитрой операции я частенько обнаруживал, что на форме оставалось лишь два ToolStrip-а из трех. При определенных условиях при уменьшении ширины формы крайние правые ToolStripItems не сбрасываются в overflow меню, а просто исчезают. :) Не стоит и говорить, что баг-репорты со словом ToolStrip описывают еще небольшой вагончик подобных "трудностей роста". Применение новой технологии прямо сегодня в коммерческом проекте было бы, несомненно, оплошностью. Тем не менее совершенно ясно, что она пришла "всерьез и надолго", и усилия, затраченные сегодня на ее изучение, окупятся. Причем, будем надеяться, в самом ближайшем будущем.

В качестве такого "опережающего" изучения разберу следующий концептуальный момент новой технологии. Что получится во время исполнения, если поместить на форму TabControl (самый обычный System.Windows.Forms.TabControl) с парой TabPages, TabPage1 и TabPage2, на каждую из этих двух закладок поместить по персональному ToolStripContainer, а на каждый ToolStripContainer – по паре ToolStrip с произвольным набором элементов? Естественный порыв любого разработчика сказать: "А что тут такого? Что накидали – то и увидим!". Действительно, на первый взгляд никакого криминала – один контейнер (ToolStripContainer) помещен внутрь другого (TabPage), обычный сценарий. Не совсем... Дело в том, что ToolStripContainer по своему дизайну предназначен для ФОРМЫ, а не для любого другого контейнера. МСДН прямо говорит о нем:

ПРИМЕЧАНИЕ

Предоставляет панели с каждой стороны формы и центральную панель, на которой могут размещаться один или несколько control-ов.

Обратите внимание на слово Form вместо более общего "parent control" либо "container". А поэтому правильный ответ на вопрос – на TabPage1 мы увидим и ToolStripContainer, и оба его ToolStrip-а. На TabPage2 мы увидим ТОЛЬКО ToolStripContainer безо всяких признаков его ToolStrip-ов. И это притом, что в дизайнере все будет показываться просто отлично на обоих TabPages. Ну да, никто и не обещал, что будет легко. :) А вывод очень простой – используйте ToolStripContainer по его прямому назначению и внимательно читайте инструкцию по эксплуатации. :)

MaskedTextBox

Новая версия фреймворка предлагает новый control MaskedTextBox, позволяющий достичь невиданного до сих пор контроля над вводом пользователя. Идея его достаточно незатейлива –определяется маска, которой пользователь обязан следовать при вводе. Иначе ему тем или иным способом дают понять, что он не прав. :) Неудивительно, что главное свойство нового control-а так и называется Mask. Оно принимает/возвращает строку символов, которая и будет/является текущей маской ввода. Изначально оно равно пустой строке, и новый control ничем не отличается от традиционного однострочного TextBox-а. Замечу, что рассматриваемый control принципиально не может быть многострочным, и его свойство Multiline всегда возвращает false. Для "включения" новых особенностей нужно задать значение свойства Mask – текстовую строку определенного формата. В дизайнере можно посредством "умного ярлыка" (smart tag), находящегося в верхнем правом углу control-а, вызвать пункт “Set Mask...” (собственно, это единственный пункт в меню "умного ярлыка") и в открывшемся диалоговом окне выбрать маску из предопределенных вариантов. К примеру, предлагаются маски для корректного ввода Zip Code, времени и даты, телефона с кодом региона и прямого и т.п. При этом в двух нижних полях показывается, как выглядит "код" маски (или, иначе, та самая строка, что будет присвоена свойству Mask), и что увидит пользователь, когда откроется форма с control-ом. А увидеть он может вот что: во-первых, сама маска может содержать литеральные символы, отображаемые "как есть" в поле ввода; и во-вторых, свойство PromptChar показывает, какой символ будет "заполнителем" того пространства, куда позже пользователь будет обязан сделать реальный ввод. По умолчанию таким символом является подчеркивание (_). Таким образом, если маска выглядит как "(999) 000-0000" то пользователь увидит изначально поле ввода вида "(___) ___-____" (и то, и другое без кавычек). Это говорит пользователю, что ожидается ввод трех цифр в скобках (код региона), трех до тире и четырех – после. По мере набора символы подчеркивания заменяются вводимыми символами. В основе повышенной гибкости нового control-а в вопросе отслеживания пользовательского ввода лежит его способность принимать пользовательские, "самописные" маски. Символы, которые можно использовать в маске и их назначение приведены в таблице 5.

Символ маскиОписание
0Цифра, ввод обязателен. Пользователь вправе ввести любую цифру из диапазона 0-9
9Цифра или пробел. Ввод необязателен.
#Цифра, пробел, плюс или минус. Ввод необязателен.
LБуква, ввод обязателен. Аналог конструкции [a-zA-Z] в регулярных выражениях.
?Буква, ввод необязателен. Аналог конструкции [a-zA-Z]? в регулярных выражениях.
&Символ (в т.ч. Unicode), ввод обязателен. Любой не управляющий символ. При значении true свойства AsciiOnly данный символ маски совершенно аналогичен символу “L”
CСимвол (в т.ч. Unicode), ввод не обязателен. Любой не управляющий символ. При значении true свойства AsciiOnly данный символ маски совершенно аналогичен символу “?”
AБуквенно-цифровой символ (Unicode не принимается), ввод обязателен. При значении true свойства AsciiOnly данный символ маски позволяет ввод лишь ASCII букв a-z и A-Z.
aБуквенно-цифровой символ (Unicode не принимается), ввод опциональный. При свойстве AsciiOnly приравненным к true данный символ маски позволяет ввод лишь ASCII букв a-z и A-Z.
. (точка)Место, где будет отображен десятичный разделитель. Какой символ будет использоваться, определяется свойством Culture control-а.
, (запятая)Место, где будет отображен разделитель тысяч. Какой символ будет использоваться, определяется свойством Culture control-а.
: (двоеточие)Место, где будет отображен разделитель времени. Какой символ будет использоваться, определяется свойством Culture control-а.
/ (прямой слеш)Место, где будет отображен разделитель даты. Какой символ будет использоваться, определяется свойством Culture control-а.
$Место, где будет отображен символ валюты. Какой символ будет использоваться, определяется свойством Culture control-а.
<Весь ввод пользователя после этого символа маски автоматически приводится к нижнему регистру.
>Весь ввод пользователя после этого символа маски автоматически приводится к верхнему регистру.
|С этого места отменить действие любого из двух предыдущих символов маски. Принимать ввод пользователя в том регистре, в каком он его осуществляет.
\ ( обратный слеш)Если любой из указанных выше символов надо перевести в разряд литеральных, “\\” отобразит в маске обратный слеш, “\L” – символ L и т.д.
Любой символ, не указанный выше Отображается в маске "как есть", т.е. является литеральным. Такой символ всегда занимает фиксированное положение и не может быть каким-либо образом изменен пользователем (сдвинут, удален и т.п.).
Таблица 5.

Сразу хочу сказать, что эта таблица является, так сказать, калькой аналогичной таблицы из MSDN. В то же время нехитрые опыты говорят, что при свойстве AsciiOnly=false все символы, долженствующие (согласно таблице) принимать лишь первые 127 символов (a-zA-Z), с успехом принимают и вторую часть таблицы ASCII, то есть допускают ввод кириллицы. А вот при значении AsciiOnly=true пользователь реально ограничен лишь первыми 127 символами, т.е. латиницей. Обратите внимание, что если вы хотите разрешить ввод Unicode-символов, то, во-первых, необходимо убедиться, что свойство AsciiOnly приравнено к false, а во-вторых, использовать в коде маски только символы “&” и “C”. Только эти два символа маски готовы принять от пользователя Unicode-символы. Примеры масок приведены в таблице 6.

МаскаРасшифровка
(999) 000-0000Ожидается ввод десяти цифр телефонного номера. Первые три – опциональны, вторые 7 – обязательны для ввода. Ввод других символов (кроме цифр) недопустим.
000->AAA-AAA-000Ожидается ввод трехзначного числа, затем 6 любых символов (цифры и/или буквы), затем еще одно трехзначное число. Все буквы будут автоматом приведены к верхнему регистру.
<&&&&|CCCCОжидается ввод любых (в т.ч. Юникод) 8 символов. Первые 4 обязательны для ввода и будут автоматически приведены к нижнему регистру. Вторые 4 необязательны и могут быть введены в любом регистре.
90:00Ожидается ввод времени. Часы могут быть указаны одной или двумя цифрами. Минуты – только двумя (05, но не просто 5). Разделитель времени будет показан перед минутами. Его точное представление определяется свойством Culture control-а.
9,999.00$Ожидается ввод суммы в диапазоне 0.00-9999.99. Десятичный разделитель, разделитель тысяч и символ валюты определяются свойством Culture control-а. На моей машине пример ввода выглядит так: "7'902.14р." (без кавычек).
Таблица 6.

Вкратце упомяну, что движок разбора масок отнюдь не прошит жестко в недрах control-а MaskedTextBox. Вместо этого существует отдельный сервис, представленный классом System.ComponentModel.MaskedTextProvider. Именно он отвечает за то, что в позицию маски, кодированную нулем, должна быть проставлена цифра (но не буква). А в позицию, кодированную ‘C’, может быть проставлен любой юникод-символ. Вы можете создать класс-наследник указанного класса и сказать, к примеру, что символ ‘X’ в маске будет требовать ввода любой шестнадцатеричной цифры, т.е. 0-9 или A-F. А символ ‘x’ будет лишь допускать такую возможность, т.е. сделает ввод шестнадцатеричной цифры опциональным. После изготовления такого движка вы должны будете “скормить” его control-у MaskedTextBox через перегруженный конструктор

public MaskedTextBox(MaskedTextProvider maskedTextProvider);

И тогда данный экземпляр контрола будет понимать ваши собственные коды маски. Рассказ о практической реализации такого движка выходит за рамки данной публикации ввиду обширности темы. Но не упомянуть о такой возможности и лишний раз не восхититься гибкостью предлагаемых разработчиками и архитекторами MS решений было невозможно.

Итак, как уже говорилось, литеральные коды маски (те же круглые скобки и дефис в первом примере предыдущей таблицы) являются для пользователя нашего приложения абсолютно неизменяемыми символами. Они видны ему всегда, начиная с момента открытия формы, на которой лежит control MaskedTextBox, и он никак повлиять на них не может. В тоже время символ-заполнитель (PromptChar, подчеркивание по умолчанию) изначально пользователю НЕ виден. Он увидит его лишь в тот момент, когда control получит фокус клавиатурного ввода. А с потерей фокуса пользователь вновь потеряет их из вида. Лично мне это представляется неоднозначным решением. Постоянное мельтешение пропадающих/возникающих символов может вывести из себя кого угодно. Тем более, представьте форму ввода некой персональной карточки с двадцатью control-ами MaskedTextBox. И каждый будет мигать, как новогодняя елка, при очередном нажатии клавиши Tab. По счастью, разработчики control-а рассуждали примерно так же и ввели специальное свойство HidePromptOnLeave. Стоит установить его в false, и вся вакханалия с появляющимися/исчезающими подчеркиваниями прекращается. Они видны всегда, независимо от фокуса ввода. По умолчанию это свойство имеет значение true, и вот с этим бы я поспорил, но это уже мелкие шероховатости и придирки.

Вы могли заметить, что символы маски сгруппированы попарно (L+?, &+C) по шаблону ввод обязательный+ввод опциональный. Чем же первый отличается от второго с точки зрения разработчика? А вот чем. У control-а есть свойство только-для-чтения MaskCompleted (тип bool). К нему можно в любой момент обратиться и получить true, если все требуемые позиции маски заполнены. Проще говоря, при маске “aaa-aaa” это свойство сразу же вернет true – нет символов, обязательных к вводу. А при маске “aaa-A” пользователю достаточно ввести один символ (после дефиса) и ввод будет засчитан как правильный. Вполне логично в пару к предыдущему идет свойство MaskFull. Оно, как несложно догадаться, вернет true, только если все (обязательные + опциональные) элементы маски получили допустимые символы. Т.е. при любой маске это свойство изначально обязательно false.

Теперь зададимся вопросом: а что, если пользователь, находясь в control-е MaskedTextBox и выполнив некоторую часть ввода (или завершив его), захочет скопировать введенное им значение в буфер, чтобы затем вставить его же в другое приложение? Что же, в буфер попадут все эти скобочки-подчеркивания? Не факт. Все зависит от значения свойства CutCopyMaskFormat. Оно может принять любое из четырех значений перечисления MaskFormat. Как влияет это свойство на работу с буфером, проще всего продемонстрировать на примере. Итак, маска имеет вид {999}- (000)-[000]. Надеюсь, что вы уже успели стать "опытными маскировщиками" :) и без труда поймете, что от пользователя ожидается ввод трех групп чисел по три цифры в группе, причем группа в фигурных скобках не обязательна. В качестве символа-заполнителя (PromptChar) используется подчеркивание. Пусть воображаемый пользователь ввел несколько цифр, и на текущий момент поле ввода имеет такой вид: {___}- (987)-[2__]. И в этот момент жмутся клавиши Ctrl+A, а за ними Ctrl+C. Что в буфере? Смотрим таблицу:

Значение свойства CutCopyMaskFormatВ буфере окажется…
ExcludePromptAndLiterals<сначала 3 пробела – незаполненные символы фигурных скобок>9872
IncludeLiterals{ }- (987)-[2 ]
IncludePrompt___9872__
IncludePromptAndLiterals (по умолчанию){___}- (987)-[2__]
Таблица 7.

Думаю – все ясно. Можем включать литеральные и символы-заполнители в копируемое содержание как только заблагорассудится, в любой комбинации, а также исключать оба элемента, оставляя лишь реально введенные символы. Все рассуждения о многообразии форматов для буфера обмена в равной степени справедливы и для свойства Text control-а MaskedTextBox. Только здесь уже управление берет на себя свойство TextMaskFormat, принимающее одно из все тех же четырех значений из таблицы 7. По умолчанию оно так же равно CutCopyMaskFormat.IncludePromptAndLiterals. Поэтому обращение к свойству Text вернет (по умолчанию) “полновесную строку”: символы приглашения + литеральные символы + символы, введенные пользователем к моменту обращения к Text. Обратите внимание на эту существенную разницу по сравнению с обращением к одноименному свойству из стандартного TextBox. В control-е MaskedTextBox при конфигурации по умолчанию чаще всего будет скопирована изрядная порция “избыточной” информации. Если требуется узнать “чистый” ввод от пользователя (т.е. имитировать поведение TextBox), лучше задать TextMaskFormat значение ExcludePromptAndLiterals и только после этого запросить свойство Text.

Есть еще одна потенциальная проблема, способная встать перед разработчиком. Ее, на самом деле, желательно исключить на этапе проектирования приложения. А что, если пользователю придет в голову подсунуть в поле ввода символ, совпадающий с символом-заполнителем? Как я уже сказал – такой ситуации лучше избегать, изначально подбирая совершенно уникальный символ-заполнитель, который пользователю или гарантированно не понадобится или даже который невозможно будет ввести. Кстати используемый по умолчанию символ-заполнитель выбран достаточно удачно. Этот символ (подчеркивание) разрешен к вводу ТОЛЬКО если в маске присутствует элемент « или элемент «. При любых других элементах маски ввод пользователем подчеркивания исключен по определению – сама маска отвергнет его, хотя точнее это зависит от установок двух свойств, речь о которых пойдет чуть ниже. Но гарантированно можно сказать, что именно как ввод нового символа подчеркивание засчитано не будет. Если требуется еще большая уникальность, можно использовать в качестве разделителя любой юникод-символ, к примеру, «€». Заметьте, что PromptChar может быть абсолютно любым символом, независимо от того, что прописано в маске. Заполнитель может быть юникод-символом, даже если маска таковых не принимает. Но что, если маска все же состоит из элементов &/C? Тут все сложнее. Потенциально пользователь может ввести любой символ: «_», «€» и т.д. Как разрешить ситуацию с вводом символа, равного символу-заполнителю в этом случае? В этом случае используется пара упомянутых ранее свойств: ResetOnPrompt и AllowPromptAsInput. Они оба имеют тип bool и работают в связке, причем первое имеет приоритет перед вторым. Разберем все возможные сочетания и их значения (таблица 8).

Значение ResetOnPromptЗначение AllowPromptAsInputПоведение маски при вводе символа, совпадающего с PromptChar
True (по умолч.)Любое.
По умолч. – true.
Ввод такого символа просто очищает текущую позицию маски, т.е. вновь делает ее пустой, как в начале ввода. Обратите внимание, что при этом в случае маски вроде "AAA" (которая вообще-то не допускает ввод подчеркивания и уж, тем более, юникод-символов), пользователь МОЖЕТ напечатать “_” (если, конечно, используется значение PromptChar по умолчанию). Более того, если PromptChar будет равен, к примеру, "€" (юникод), то пользователь при желании сможет ввести и его. Но надо четко понимать – при таких установках двух рассматриваемых свойств ввод, подобный описанному, не является вводом значащих символов. Это не более, чем удобный (?? – с точки зрения авторов контрола; я лично в этом не уверен) способ стереть текущую позицию в маске. Совершенно аналогичного результата пользователь может добиться и клавишей Delete, и клавишей Backspace и т.п. Кстати, дизайн рассматриваемого control-а таков, что нажатие пробела абсолютно аналогично вводу PromptChar, а именно: символ в текущей позиции каретки заменяется на PromptChar (визуально), и становится пустым (логически). Правда, это поведение пробела по умолчанию, которое может быть изменено свойством ResetOnSpace (см. ниже).
FalseTrue (по умолч.)Ввод PromptChar засчитывается как ввод значащего символа. Т.е. он рассматривается как совершенно равноправный ввод или попытка такового. В частности, при маске "AAA" пользователь не сможет ввести ни "_", ни "€", т.к. подобный ввод запрещен самой маской. Но вот при маске "CCC" можно будет ввести и то и другое. Причем это будет именно полноценный ввод – как визуально, так и логически. Тут возможна небольшая "западня" при попытке работы с событием control-а TextChanged. Подробнее см. параграф "На что обратить внимание" этого раздела.
FalseFalseВвод PromptChar-а невозможен в принципе, даже если он и разрешен маской. Т.е. он просто отвергается физически и не работает даже в качестве "ластика". При попытке такого ввода генерируется событие MaskInputRejected. О нем см. параграф "Обработка некорректного ввода".
Таблица 8.

Обратите внимание, что настройки по умолчанию предписывают PromptChar работать именно "ластиком", хотя того же самого результата куда проще добиться пробелом. Кстати, о пробеле и обещанном свойстве ResetOnSpace (типа bool). Вся описанная выше эквивалентность “ластиковой” работы пробела и PromptChar справедлива при значении этого свойства по умолчанию – true. Если же изменить его на противоположное, пробел перестанет быть “ластиком”. Вместо этого он станет нормальным, обычным символом ввода, т.е. будет сопоставлен с текущим символом маски и будет либо принят, либо отвергнут. Таким образом, если планируется потенциальный ввод пробелов (это не типично, но может быть полезно), придется отказаться от “пробела-ластика” и установить рассматриваемое свойство в false. Иначе ввод пробела (как значащего символа) в принципе будет невозможен.

Что еще интересного осталось в control-е? Масса всего. К примеру свойство InsertKeyMode позволяет определить режим вставки в поле. Оно принимает одно из трех возможных значений перечисления InsertKeyMode:

В MSDN по поводу рассматриваемого свойства есть одна "ценная" оговорка:

Это свойство может быть переименовано в InsertMode до выхода конечной версии .NET Framework 2.0 и Visual Studio 2005.

Текущий режим вставки (если это интересно) позволяет получить read-only свойство IsOverwriteMode. Оно оценивает предыдущее свойство и, в случае необходимости, еще и состояние клавиши INSERT, после чего возвращает true, если новый ввод перезаписывает старый и false – если он сдвигает его вправо.

Еще авторы control-а предусмотрительно озаботились вводом т.н. "чувствительной" информации, то есть паролей, серийных номеров и пин-кодов кредиток. Здесь работают сразу два свойства: UseSystemPasswordChar (тип bool) и PasswordChar (тип char). Первое имеет преимущество над вторым – если оно выставлено в true, то, независимо от состояния второго, весь пользовательский ввод визуально представляется в виде “символа-пароля”, заданного на уровне системы. Для Windows XP это по умолчанию черный кружок. Если же описываемое свойство равно false (по умолчанию), то оценивается второе свойство. Если оно (свойство PasswordChar) равно ‘\0’ (по умолчанию), вводимая информация считается обычной и отображается как есть. Но вот присваивание любого другого символа заставит ввод пользователя визуально выглядеть как последовательность именно этих самых символов. Две не совсем ожидаемых “западни”: PromptChar (именно приглашающий символ, не путайте с PasswordChar!) не должен быть равен упомянутому системному “символу-паролю”; и PromptChar не должен быть равен PasswordChar. При нарушении любого условия возникает InvalidOperationException.

Теперь такая ситуация. Передставьте маску “aaa-9999”. Ожидается ввод 3 букв/цифр, за которыми следуют 4 цифры. Но ведь пользователь не всегда будет вводить их путем нажатия на клавиши! Он может скопировать всю входную информацию (или часть ее) из другого приложения и просто вставить в control. Если он скопирует что-то вроде “zyx0978” – не вопрос, все прекрасно вставится. А что, если в буфере окажется “ zyx4A9T”? Как это вставить и вставлять ли вообще? Тут командовать парадом будет свойство RejectInputOnFirstFailure, по умолчанию имеющее значение false. На интуитивном уровне это означает поведение control-а по шаблону “пытаться вставлять максимальное количество подходящих символов”. А именно: после нажатия Ctrl+V в поле ввода окажется “zyx-49__”. При этом дважды (для символа “A” и “T”) будет сгенерировано событие MaskInputRejected (подробнее о нем рассказано в параграфе "Обработка некорректного ввода"). А что будет, если RejectInputOnFirstFailure присвоить значение true? Шаблон поведения изменится на “вставлять только весь буфер или не вставлять ничего”. Т.е. после нажатия Ctrl+V в этом варианте наше поле ввода останется просто пустым, т.к. разбор строки будет прерван символом “A”, стоящим не на своем месте, и событие MaskInputRejected будет сгенерировано лишь однажды – для первого “плохого” символа.

Маски почти наверняка будут содержать так называемые “литеральные” символы. Например, в маске “000-999” (3 обязательных + 3 необязательных цифры) таковым является дефис. Он отображается в поле ввода “как есть”, но при этом (для данной маски) запрещен к вводу. Что же будет, если пользователь попытается все же ввести его? Ответ зависит от того, где будет находиться в этот момент каретка ввода, и от значения свойства SkipLiterals (тип bool). Если пользователь вводит данные в указанную маску, и упомянутое свойство имеет значение true, нажатие дефиса в момент, когда каретка находится ровно перед ним в поле ввода, засчитывается как “избыточный ввод литерального символа”, и каретка попросту прыгает за дефис. Альтернативным и более эргономическим вариантом ввода является пропуск нажатия дефиса и просто ввод следующей цифры, т.е. пользователь просто нажимает шесть клавиш с цифрами безо всяких дефисов, и маска сама их расставляет в нужные позиции – переход за дефис осуществляется автоматически. Попытка же нажать дефис в любой другой позиции каретки будет засчитана как некорректный ввод с генерированием события MaskInputRejected. При значении SkipLiterals, равном false, ввод дефиса невозможен в принципе. Пользователь вынужден будет вводить только цифры, без литерального разделителя.

На этом краткий обзор основных свойств нового control-а можно считать завершенным и с чистой совестью перейти к более насущной теме – как реагировать на неверный ввод пользователя.

Обработка некорректного ввода

Варианты некорректного ввода можно подразделить на 2 большие группы. Во-первых, невнимательный пользователь может ввести (или попытаться ввести) символ, недопустимый в данной позиции маски, а во-вторых, подходящие символы могут быть расставлены в необходимые позиции маски, но при этом вся строка В ЦЕЛОМ может быть абсолютно неприемлема. Сначала рассмотрим вариант 1 – попытку ввода неподходящего символа. Эта ситуация, вообще говоря, отслеживается самой маской, и, как правило, беспокоиться не о чем. Достаточно лишь знать, что при попытке такого ввода (который будет, разумеется, блокирован маской) control MaskedTextBox будет генерировать событие MaskInputRejected. Это событие будет сгенерировано в следующих случаях:

Заметим, что в последнем случае (попытка присвоить из кода нелегальную строку свойству Text) приведет к тому, что вся эта строка будет блокирована маской и ни единый символ из нее не появится в control-е.

Как я уже упоминал, не обязательно как-то особо работать с этим событием. Тем более, что Framework предоставляет обработчик для него (но только если свойство BeepOnError (тип bool) равно true). В этом случае попытка пользователя ввести некорректный символ заставит пискнуть системный динамик. Если же упомянутое свойство равно false (по умолчанию), то кроме блокировки ввода не произойдет ровным счетом ничего. Справедливости ради стоит сказать, что я ставил на своей машине BeepOnError в true и пытался осуществить некорректный ввод. Разумеется, ввод блокировался, но динамик молчал как партизан. Возможно, его просто заглушал кулер моей новой видеокарты. :) В любом случае, поскольку для реальных проектов писк динамика является крайне неинформативным сообщением, я не стал глубоко разбираться с этой “проблемой”. В реальных приложениях пользователь вправе ожидать более дружественной подсказки, чем невнятный и не всегда слышный system beep. Именно это может заставить разработчика время от времени подписываться на это событие и решать, когда, как и в каком объеме сообщить пользователю о некорректном вводе. Как пример возможной реализации, моя демо-форма (см. следующий раздел) просто “зажигает” на 5 сек всплывающую подсказку с пояснением ошибки. Заголовком этого всплывающей подсказки служит строка "Error in position N " и номер позиции, где произошла попытка неверного ввода. Эта информация любезно предоставляется во втором аргументе обработчика. Данный аргумент имеет тип MaskInputRejectedEventArgs, и вот как раз его-то свойство Position приходится как нельзя кстати. Оно сообщает искомую позицию попытки некорректного ввода. Самый первый символ строки оно нумерует нулем, так что при выводе сообщения не забудьте использовать нечто вроде:

(e.Position + 1).ToString()

для перевода номера “проблемной” позиции на “человеческий” язык. А в качестве же текста всплывающей подсказки я использую второе (и последнее) свойство упомянутого аргумента – RejectionHint. Как и следует из его названия, оно предоставляет тот самый “хинт”, помогающий разобраться в причинах ошибки ввода. Возвращает это свойство не строку, а одно из значений перечисления MaskedTextResultHint. Поэтому получается, что прежде всего это “хинт” для разработчика. :) В моей демо-форме значение из данного перечисления просто конвертируется в строку и выдается “как есть”. В реальном приложении, разумеется, более профессиональным подходом будет анализ возвращаемого значения и выдача пользователю “дружелюбной” подсказки. Разбирать каждое значение не стоит – их названия одновременно являются и самоописаниями. Поэтому в данном случае беглого взгляда в MSDN будет более чем достаточно для понимания.

Во втором варианте все символы на местах, но в целом ввод неверен. Как такое может быть? Самый простой пример – ввод даты в полном формате – по две цифры на день/месяц (с первым нулем, если то или другое меньше 10) и четыре на год. Какую маску задать в этом случае? Очевидно “00/00/0000” –нужны все восемь цифр. Но проблемы на этом не кончаются. Конечно, маска сама сделает немало. Например, совсем уж “дикий” вариант вроде ввода во все восемь позиций букв будет успешно блокирован. Но что помешает пользователю ввести такое: “47/32/0025”? Правильный ответ – никто и ничто. Маска не знает и не может знать, что первая пара цифр должна образовать число, лежащее в диапазоне 1-31, вторая – в диапазоне 1-12 и т.д. Уж тем более она никак не может знать, что первый диапазон еще и зависит от второго – в разных месяцах разное количество дней. Конечно, можно позволить пользователю ввести что угодно, а по нажатию кнопки вроде “Accept” судорожно проверять предложенные варианты. Но control MaskedTextBox предлагает куда более изощренный механизм контроля “ввода как целого”. Вкратце технология такова: свойству контрола ValidatingType (тип Type) присваивается тот тип, который будет разбирать предложенный вариант ввода. Причем он разбирает, еще раз повторюсь, уже комплектный (!) ввод, а не ввод каждого отдельного символа. Но а как же узнать об успехе/провале проверки? Для этого придется подписаться на событие TypeValidationCompleted. Именно в нем сообщается, насколько успешным был ввод уже с точки зрения “полновесной” логики для ожидаемого значения. “Скелет” системы контроля для предложенного варианта с датой мог бы быть таким:

private void Form1_Load(object sender, EventArgs e)
{
    maskedTextBox1.Mask = "00/00/0000";
    maskedTextBox1.ValidatingType = typeof(System.DateTime);
    maskedTextBox1.TypeValidationCompleted += 
     maskedTextBox1_TypeValidationCompleted;
}

void maskedTextBox1_TypeValidationCompleted(object sender, 
  TypeValidationEventArgs e)
{
  // здесь проверка корректности ввода и разнообразные действия в случае 
  // “плохого” ввода, а в случае ввода “хорошего” мы можем получить здесь 
  // объект, сконвертированный из строкового ввода
}

Резонный вопрос – ну хорошо, проверку даты/времени провести можно. А как быть с пользовательским типом, например, с неким серийным номером? Его можно проверить в рамках предлагаемой схемы? Конечно! Именно пример такого пользовательского парсера я и собираюсь привести прямо сейчас, а заодно подробнее рассмотреть работу с событием TypeValidationCompleted. В качестве примера я возьму форму регистрации shareware-продукта. Пользователь вводит серийный номер, который отсылается на центральный сервер и сравнивается с реально выписанными. Но перед этим неплохо выяснить – а хотя бы потенциально данный номер может быть верным? Ну что ж – поставим формальные условия к нашему номеру:

  1. Сначала идут 3 обязательные буквы из диапазона A-F в любом регистре.
  2. Затем 4 обязательные цифры. Причем все цифры должны быть либо четными, либо нет. Смешанные цифры не допустимы. В случае четных цифр серийный номер будет обеспечивать функционирование приложения в режиме Professional.
  3. И, наконец, еще 3 буквы из диапазона A-Z только в верхнем регистре. При этом для четных цифр данный ввод обязателен, а для нечетных - нет. Опять же заполнение этой части номера служит признаком версии Professional.

Начну, как положено, с создания объекта – серийного номера. Именно его нужно будет передать на сервер в случае успешной проверки:

public struct SerialNumber
{
  private char[] _part1;
  private ushort _part2;
  private char[] _part3;

  public SerialNumber(char[] part1, ushort part2, char[] part3)
  {
    ...
  }

  public SerialNumber(string part1, ushort part2, string part3)
  {
    ...
  }

  //is Pro version?
  public bool IsProfessional 
  { 
    get 
    {
      return _part3[0] != '\0' 
          && _part3[1] != '\0' 
          && _part3[2] != '\0'; 
    } 
  }
        ...
}

Маска номера будет выглядеть как “LLL-0000->???”. Абсолютно очевидно, что сама по себе маска может обеспечить лишь самый поверхностный контроль ввода серийного номера. Реальную работу возьмет на себя парсер. Для его реализации достаточно, чтобы тип, переданный свойству ValidatingType, реализовывал хотя бы один (но можно и оба) из методов с сигнатурами такого вида:

public static Object Parse(string) 
public static Object Parse(string, IFormatProvider)

В данном случае вполне можно “встроить” парсер внутрь структуры – серийного номера:

private void Form1_Load(object sender, EventArgs e)
{
  ...
  maskedTextBox1.ValidatingType = typeof(SerialNumber);
  ...
}

public struct SerialNumber
{
  ...
  // Собственно парсер
  public static object Parse(string s)
  {
    string p1, p2s, p3; ushort p2;

    p1 = s.Substring(0, 3);
    p2s = s.Substring(4, 4);
    p3 = s.Substring(9);

    // 1. Проверка диапазона A-F (любой регистр) для первой части номера
    if  (!IsInRange(p1, 'a', 'f', true))
      throw new ArgumentException("Bad range", "part1");

    // 2. Проверка четности/нечетности для второй части номера
    p2 = StringToUshort(p2s);

    if (p2 % 2 == 1)
      p3 = string.Empty;
      // 3. Проверяемый диапазон A-Z (верхний регистр) для part3, 
      // но только если part2 - четное
    else if (String.IsNullOrEmpty(p3) || p3.Length!=3 
      || !IsInRange(p3, 'A', 'Z', false))
      throw new ArgumentException("Bad range or missing", "part3");

    return new SerialNumber(p1, p2, p3);
  }
  ...
}

Я выбрал более простую первую сигнатуру. Метод с помощью несложных вспомогательных функций разбирает строку и, если убеждается в корректности ввода, возвращает новый объект типа SerialNumber. А вот что он делает в противоположном случае, я покажу через пару абзацев. Хорошо, теперь, когда есть метод, проводящий анализ пользовательского ввода, и control оповещен об этом (через свойство ValidatingType), осталось подписаться на событие TypeValidationCompleted. Ибо только в нем и выясняется результат работы парсера:

private void Form1_Load(object sender, EventArgs e)
{
  ...
  maskedTextBox1.TypeValidationCompleted += 
    new TypeValidationEventHandler(maskedTextBox1_TypeValidationCompleted);
}

void maskedTextBox1_TypeValidationCompleted(
  object sender, TypeValidationEventArgs e)
{
  int showSec;
  string s, title;
  ToolTipIcon ttIcon;
  s = "IsValidInput: " + e.IsValidInput.ToString() + Environment.NewLine 
    + "ReturnValue: " 
    + (e.ReturnValue == null ? "NULL!" : e.ReturnValue.ToString()) 
    + Environment.NewLine + Environment.NewLine 
    + "Message about conversion process: " + Environment.NewLine + e.Message;
  object o = e.ReturnValue;
  ...
}

Вспомните, что я настойчиво подчеркивал – в первом варианте (разобранном выше) контролируется каждый символ, а во втором – только комплектный ввод. Это действительно важно понимать. В частности, отсюда следует, что проверка выполняется в момент ухода фокуса ввода из control-а только если свойство MaskCompleted равно в этот момент true. Другими словами, пока все обязательные символы не поставлены в соответствующие “гнезда” маски, попытка проверки даже не предпринимается, и метод public static object Parse (string s){…} простаивает без дела. И это логично. Но событие TypeValidationCompleted будет сгенерировано даже если MaskCompleted равно false. Другими словами, событие TypeValidationCompleted генерируется при каждом уходе фокуса с control-а. Только в случае MaskCompleted равного false оно априори сообщит о неудачном парсинге (которого реально и не будет) и, разумеется, вернет в качестве результата работы парсера NULL. А что вообще интересного можно узнать, находясь внутри рассматриваемого события? Ну, второй аргумент (типа TypeValidationEventArgs) может предоставить кое-какую действительно интересную информацию. Во-первых, его свойство IsValidInput (тип bool) сообщает, была ли попытка парсинга удачной. Понятно, что пока MaskCompleted равно false, это свойство никак true вернуть не сможет. А пока оно будет возвращать false, другое свойство – ReturnValue (тип object) будет гарантированно возвращать NULL. И только когда IsValidInput вернет true, ReturnValue, в свою очередь, вернет тот объект, который парсер сформирует из “хорошей” строки. В данном примере он вернет объект SerialNumber. Вернусь к вопросу, оставленному без внимания чуть раньше: а что делает парсер, когда убеждается в некорректности ввода? Как он сообщает это? MSDN говорит по этому поводу следующее:

ПРИМЕЧАНИЕ

Parse вызывается до того, как control MaskedTextBox генерирует событие TypeValidationCompleted. Этот метод вызывается с содержимым свойства Text класса MaskedTextBox, за исключением символов подсказки. В случае удачи он возвращает конвертированный объект, иначе - null.

Могу заверить читателей, что эта фраза… не до конца правдива. :) То, что Parse вызывается до указанного события, верно лишь при условии, что MaskCompleted равно true. Иначе Parse просто пропускается. То, что Parse получает на вход содержимое свойства Text за вычетом приглашающих символов – абсолютно верно. То, что в случае успеха он должен вернуть сконвертированный объект – тоже правда, а вот то, что в случае неудачи должен быть возвращен NULL, не имеет к реальности никакого отношения. Если возвратить из парсера упомянутый NULL, конвертация будет признана удачной: внутри события TypeValidationCompleted свойство IsValidInput вернет true, а NULL будет рассматриваться как “легальный” сконвертированный объект. Это выглядит вполне резонно, поскольку наверняка будут ситуации, когда вовсе и не понадобится никаких новых объектов от пользовательского парсера - вполне достаточно простой строки текста, успешно проходящей парсинг. Зачем же плодить ненужные объекты, если можно просто обратиться к свойству Text control-а? Вот как раз здесь возврат NULL из парсера вполне уместен даже при успешной операции. Ну а как же сообщить событию TypeValidationCompleted, что парсинг был действительно неудачным? Как показали эксперименты и Reflector, для этого нужно внутри Parse просто сгенерировать исключение, причем любое. :) Оно будет перехвачено механизмом проверки, а свойство IsValidInput наконец-то вернет false. В примере я генерирую исключения типа ArgumentException. Это довольно удобно, т.к. во-первых соответствует сути происходящего, во-вторых позволяет описать, в чем именно состояла проблема (нельзя смешивать четные/нечетные числа, к примеру), и, в-третьих, позволяет указать проблемное место ввода (part2, к примеру). Причем упомянутые «во-вторых» и «в-третьих» – отнюдь не просто эстетические изыски. Дело в том, что у второго аргумента события TypeValidationCompleted есть еще одно свойство – Message (тип string). Оно описывает в читаемой форме, как прошел процесс парсинга. В частности, когда событие генерируется в тот момент, когда MaskCompleted равно false (и, как следствие, метод Parse даже не вызывается), оно сообщает: “Mask input is not completed”. А вот если произошел реальный парсинг, который закончился генерацией исключения, то Message сообщит о типе исключения, а также приведет ту текстовую информацию, которую задал разработчик при формировании исключения. Ну и чтобы завершить рассмотрение интересных свойств, доступных изнутри события TypeValidationCompleted, упомяну о свойстве Cancel (тип bool). Данное событие генерируется (прежде всего, хотя и не только) при уходе фокуса из control-а. Так вот, если внутри события приравнять Cancel значению true, то тем самым фокус будет возвращен назад, и пользователю придется предпринять новую попытку. Значение false, напротив, разрешит фокусу переместиться к очередному control-у. Я упомянул, что событие может быть сгенерировано не только при уходе фокуса из control-а. И это действительно так. Вы в любой момент можете инициировать процесс проверки из кода вызовом метода MaskedTextBox.ValidateText(). Этот метод не имеет аргументов, возвращаемый им тип – object. Фактически, при его вызове происходят все те же самые события, что и при уходе фокуса: генерируется событие TypeValidationCompleted, возможно, отрабатывает Parse и т.д. Но фокус никуда не перемещается. Возвращенный object – это сконвертированный из строки объект (если Parse отработал успешно) или NULL (если попытка парсинга была неудачной). Впрочем, о зыбкости связи "неудачный парсинг"-"возвращенный NULL" уже говорилось выше. Обычно это так, хотя будут ситуации, когда парсеру просто нечего вернуть, кроме NULL, просто по той причине, что не каждая проверяемая строка безусловно представляет собой “заготовку” для нового объекта.

Как это выглядит

Демо-пример данного раздела запускается кнопкой "Masked Edit" в главной форме. В отображаемой форме вы можете настроить основные свойства control-а MaskedTextBox. После нажатия кнопки “SetUp!” control готов к испытаниям. Все подписи практически дословно повторяют названия контролируемых ими свойств MaskedTextBox. Небольшие пояснения требует, пожалуй, лишь выпадающий список “On user input error:”. Он содержит три значения:

Ниже самого control-а находится небольшая информационная панель, отражающая изменения трех важных свойств control-а: MaskCompleted, MaskFull и Text. Изменения отражаются динамически, после ввода каждого символа. Кроме того, не забывайте, что эффект работы списка “Cut&Copy Mask Format:” можно увидеть, только введя какой-либо текст в control, скопировав его в буфер обмена и вставив его из буфера в любой текстовый редактор.


Рисунок 10.

На что обратить внимание

Я потратил немало времени на эксперименты с рассматриваемым control-ом и должен выразить свое уважение команде, разработавшей его архитектуру и писавшей код. Даже сейчас, в бета-версии, control показал себя как стабильный и продуманный компонент, готовый для решения самых изощренных задач, связанных с пользовательским вводом. Никаких претензий, за исключением некоторых помарок в документации (но не в самом control-е!), я высказать не могу. Состояние – хоть сейчас в бой. Единственный неоднозначный момент, с которым мне пришлось столкнуться (я упоминал о нем в разделе, посвященном работе с парой свойств ResetOnPrompt и AllowPromptAsInput), заключается вот в чем. При использовании маски вида “aaa-&&&” и стандартного приглашающего символа (подчеркивания) в конфигурации ResetOnPrompt=false и AllowPromptAsInput=true возникает ситуация, когда ввод подчеркивания возможен (только после дефиса, конечно) и должен засчитываться как значащий символ. В принципе, так и происходит, но вот удобное событие TextChanged реагирует не совсем так, как хотелось бы. Я использую его, к примеру, чтобы в демо-примере обновлять информационную панель после ввода каждого нового символа. Так вот, в приведенной конфигурации ввод приглашающего символа НЕ приводит к генерации события TextChanged, т.к. с точки зрения последнего свойство Text в этом случае не меняется. Формально придраться не к чему – свойство Text действительно содержит изначально подчеркивание на том месте, куда пользователь пытается его ввести и визуально Text не меняется. Но он меняется логически. До ввода подчеркивания это был просто символ заполнитель, после – полноценная единица ввода, оказывающая существенное влияние на статус control-а. Одним словом, здесь имеет место быть любопытная и довольно запутанная логическая коллизия. Для ее разрешения может быть применено несколько подходов. В частности, можно отслеживать нажатие каждой клавиши (событие Control.KeyDown) и если нажатый символ совпадает с PromptChar – принудительно вызывать обработчик события TextChanged. Но вызывает некоторую досаду, что разработчики control-а, даже перекрыв унаследованный от Control-а метод OnTextChanged() собственной реализацией, не учли, что текст в данном случае может меняться не только визуально, но и логически. С моей точки зрения событие должно быть сгенерировано в обоих случаях.

SplitContainer

Этот control пришел на смену старому (и значительно более незатейливому) control-у System.Windows.Forms.Splitter. Идея нового control-а достаточна проста – по сути, это две “склеенные” друг с другом стандартные панели. В эти панели можно, как обычно, набросать прочие control-ы. Склейка панелей может быть как вертикальной, так и горизонтальной, и разумеется, пользователь может во время исполнения определить удобные ему пропорции. Всем известный наглядный пример – Windows Explorer. Если бы мы писали нечто подобное на .NET, то как раз взяли бы этот новый control, растянули его на всю форму, разделили вертикально, в левую панель поместили бы control TreeView, а в правую – ListView и т.д. При этом, поместив курсор мыши над разделителем (в этот момент визуальный вид курсора изменится), пользователь смог бы выбрать комфортные для него размеры панели дерева директорий и, соответственно, панели просмотра файлов. Использовать SplitContainer можно не только на форме. Вполне можно соорудить этакий “мини-эксплорер” на одной из закладок TabControl-а. Т.е. в отличие от разобранного ранее ToolStripContainer (помните пример с попыткой поместить его на тот же самый TabControl?) рассматриваемый control может быть размещен не только на форме, а вообще на любом другом контейнере. MSDN так и говорит:

Use the SplitContainer control to divide the display area of a container (such as a Form)….

Т.е. в данном случае форма – лишь одна из разновидностей контейнеров. Отсюда следует, в частности, что можно вкладывать один SplitContainer внутрь другого. Именно так сделано в демо-примере, речь о котором пойдет ниже. А пока давайте посмотрим – какими же интересными свойствами обладает новый control? Одно из важнейших – Orientation. Оно принимает одно из двух значений одноименного перечисления – Orientation.Vertical (по умолчанию) и Orientation. HorizontalI. Как несложно догадаться, это свойство указывает, как будут разделены панели: по вертикали или по горизонтали. Непосредственно же к панелям доступ получается через read-only свойства Panel1 и Panel2. Они оба возвращают по экземпляру класса SplitterPanel, который унаследован непосредственно от System.Windows.Forms.Panel, и фактически является этой самой обычной панелью. Первое из упомянутых свойств возвращает левую/верхнюю панель, а второе – правую/нижнюю. Ясно, что конкретное взаиморасположение возвращаемых панелей зависит ни от чего иного, как от свойства Orientation. Везде далее по тексту ссылка на Panel1 может означать как левую, так и верхнюю панель. То же самое касается и Panel2. Об этом необходимо помнить. Вот, к примеру, очередное свойство нашего контейнера – FixedPanel. Оно принимает одно из трех значений одноименного перечисления: None, Panel1, Panel2, где два последних значения могут относится как к паре “левая-правая”, так и к паре “верхняя-нижняя”. А для чего же предназначено само это свойство? Представьте: мы поместили SplitContainer на форму и “заякорили” (Anchor) ее со всех четырех сторон. Тогда, очевидно, при изменении размеров формы будут пропорционально меняться и размеры контейнера. При этом по умолчанию размеры обеих панелей меняются синхронно. А происходит это потому, что значением рассматриваемого свойства по умолчанию является как раз FixedPanel.None. Если же присвоить ему любое из двух других значений, соответствующая панель станет “фиксированной”, т.е. изменение размеров внешнего контейнера (в примере – формы) ее не затронет. Изменения коснутся только противоположной панели. Еще раз подчеркиваю, что это свойство вступает в игру только при изменении внешнего контейнера, на котором размещен SplitContainer. При изменении панелей самого SplitContainer-а путем перетаскивания его разделителя данное свойство не учитывается. Зато в такой ситуации учитывается пара других свойств - Panel1MinSize и Panel2MinSize (оба типа int). Они говорят насколько близко пользователь сможет подвести разделитель к левому/верхнему и правому/нижнему краю контейнера, соответственно. Другими словами, эта пара свойств определяет, насколько узкой/низкой можно сделать каждую из двух панелей. Значение по умолчанию для обоих – 25 пикселов. Интересный момент: допустим, у нас горизонтально разделенная панель, мы зафиксировали верхнюю, а у нижней установили Panel2MinSize в 100 пикселей. И начали уменьшать высоту формы. Пока нижняя панель будет выше 100 пикселей, поведение вполне предсказуемо – верхняя панель не уменьшается, уменьшается только нижняя. Но что будет, когда высота формы и фиксированность верхней панели вынудит нижнюю панель стать высотой, допустим, 90 пикселей? Ясно, что придется какое-то правило “нарушить”. Либо проигнорировать Panel2MinSize нижней панели, либо FixedPanel – верхней. Выясняется, что второе имеет преимущество. Т.е. пока есть хотя бы теоретическая возможность, control будет поддерживать фиксированный размер панели, даже ценой уменьшения другой панели до 0 пикселей, невзирая не ее допустимый минимальный размер. А вот свойство IsSplitterFixed, установленное в true, вообще запретит пользователю перемещать разделитель. И даже хвататься за него – мышиный курсор даже не будет менять внешний вид при наведении на разделитель. Кстати, о разделителе. Как не сложно вообразить, он представляет из себя этакий длинный/высокий, но узкий прямоугольник. Но это на человеческом языке. А свойство только-для-чтения SplitterRectangle (тип Rectangle) переведет все эти длинный-высокий-узкий на сухой язык пикселей. Оно позволяет в любой момент узнать, насколько далеко разделитель отстоит от левого/верхнего угла SplitContainer-а (не формы!), а также высоту-ширину самого разделителя как геометрического прямоугольника. Если вы думаете, что из пары высота-ширина одно из измерений всегда равно соответствующему измерению SplitContainer-а, а другое фиксировано, то вы правы лишь наполовину. Действительно, высота (при вертикальной ориентации) и ширина (при горизонтальной) разделителя в точности будут равны высоте/ширине самого control-а. Так что эта информация несколько избыточна. А вот второе измерение отнюдь не является фиксированным и может быть изменено свойством SplitterWidth (тип int). По умолчанию оно равно четырем пикселам, и показывает, насколько “толстеньким/худеньким” будет разделитель. Именно это значение будет возвращено либо как Width, либо как Height SplitterRectangle. Как я уже упоминал свойство, X/Y все того же SplitterRectangle покажет расстояние (в пикселах) от левого/верхнего угла нашего контейнера до разделителя. Абсолютно аналогичное значение можно “вытащить” из свойства SplitterDistance (тип int). Но последнее обладает тем несомненным преимуществом, что помимо акссесора get имеет и set. Т.е. ему можно присвоить любое целое число, что, разумеется, приведет к “переезду” разделителя на новую позицию. Фактически, это эмуляция перетаскивания разделителя пользователем. А насколько точно последний может спозиционировать разделитель при “ручном” перетаскивании? Все зависит от свойства SplitterIncrement (тип int). По умолчанию оно равно одному пикселу, и пользователь может сдвигать разделитель чрезвычайно аккуратно. Но стоит назначить ему, допустим, значение 15, и разделитель при перетаскивании начинает двигаться “прыжками”. И, пожалуй, последняя пара свойств, заслуживающих упоминания – Panel1Collapsed и Panel2Collapsed (оба типа bool). Стоит присвоить любому из них true, и соответствующая панель совершенно исчезнет из виду, а вторая займет весь SplitContainer. В таком виде контейнер будет представлять старую, добрую System.Windows.Forms.Panel, но с возможностью в любой момент опять распахнуть исчезнувшую панель. Завершу обзор упоминанием пары событий нового control-а: SplitterMoving и SplitterMoved. Первое генерируется, когда в начале перемещения разделителя пользователем, а второе – в конце этого увлекательного процесса. Находясь внутри обработчика первого события, благодаря второму аргументу типа SplitterCancelEventArgs, можно получить текущие координаты мыши и самого разделителя. Что интереснее, присвоив свойству Cancel упомянутого аргумента true, можно отменить перемещение разделителя, инициированное пользователем, т.е. разделитель “прыгает” обратно в ту позицию, где он был в момент генерации события SplitterMoving. Таким образом, имеется возможность достаточно изощренного контроля над движением разделителя. Второе событие похоже на первое, и позволяет получить в обработчике практически ту же информацию. Но, разумеется, отменить перемещение не выйдет. Ведь само событие SplitterMoved вызывается, когда “переезд” сплитера уже состоялся.

Как это выглядит

Демо-пример данного раздела запускается кнопкой "SplitContainer" в главной форме. В появившейся форме находится 2 SplitContainer-а. Первый (условно главный, родительский) разделен изначально горизонтально, занимает не всю площадь формы, но зато “заякорен” по всем ее четырем сторонам. Panel1 у него окрашена в песочный цвет. В Panel2 этого контейнера помещен второй SplitContainer (условно подчиненный, дочерний). Он занимает всю площадь второй панели. В свою очередь он сам разделен (изначально вертикально) на две панели – голубую и зеленую. Справа формы находятся две управляющих панели. Одна для родительского, другая – для дочернего контейнера. Эти панели совершенно идентичны по функциональности и позволяют настроить и “пощупать” влияние тех свойств, речь о которых шла в предыдущем разделе. Еще раз напомню, что для понимания влияния свойства FixedPanel нужно изменить размеры внешнего контейнера, т.е. для данного примера – размеры формы. Помните, что Panel2 у родительского SplitContainer-а полностью отведена под вложенный контейнер, и поэтому на управляющей панели она представлена двумя цветными прямоугольничками – голубым и зеленым. Но логически это одна неделимая панель. Я не стал бросать на панели какие-то фиктивные control-ы, т.к. это помешало бы удобству экспериментов. Просто помните, что основная функция всех рассматриваемых в этом примере панелей – быть “домом” для других control-ов.


Рисунок 11.

На что обратить внимание

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

FlowLayoutPanel и TableLayoutPanel

Зачастую разработчикам, работающим над созданием пользовательского интерфейса, приходится создавать т.н. “формы, управляемые данными” (data-driven Windows Forms), т.е. формы, где количество и даже тип control-ов выясняются не на этапе дизайна формы, а на основе данных, динамически получаемых из БД, XML-файла и т.п. В такой ситуации крайне остро встает вопрос: как бы это так исхитрится, чтобы бросаемые на форму во время исполнения разномастные control-ы имели в целом более-менее “причесанный” вид без бесконечных расчетов и подгонок свойства Location? Новая версия Framework-а дает достойный ответ, предлагая два новых класса – FlowLayoutPanel и TableLayoutPanel, причем оба - наследники класса System.Windows.Forms.Panel. Их основная цель – сделать добавление новых control-ов простым, легким и аккуратным с визуальной точки зрения.

FlowLayoutPanel

Идея этой панели в следующем. Вся ее поверхность делится на ряды или колонки в зависимости от значения свойства FlowDirection. Далее любой кидаемый на нее control, независимо, происходит ли это во время исполнения или разработки, помещается в позицию 1. Конкретное расположение этой позиции определяется тем же свойством, но чаще всего это будет верхний-левый угол панели. Следующий добавляемый control займет очередное место в той же строке/колонке и т.д. пока не будет заполнена вся строка/колонка. Когда это произойдет и будет добавлен следующий control, возможны два варианта: если свойство WrapContents (тип bool) равно true (по умолчанию), то новый control займет первую позицию в следующей строке/колонке. Если же сделать его равным false, то такого переноса не случится, и не влезающая часть очередного добавляемого control-а просто будет срезана. Не отходя далеко от темы рассмотрим, какие же значения может принимать свойство FlowDirection, и как они влияют на расположение control-ов. Возможен один из четыре вариантов перечисления FlowDirection:

Элемент перечисления FlowDirectionПервый элемент позиционируется….Последующие элементы добавляются….Перенос добавляемого control-а происходит….
BottomUpВ левом нижнем углу панелиСнизу-вверх, в колонкуВ последнюю строку следующей колонки
LeftToRight (по умолчанию)В левом верхнем углу панелиСлева-направо, в строкуВ первую колонку следующей строки
RightToLeftВ правом верхнем углу панелиСправа-налево, в строкуВ последнюю колонку следующей строки
TopDownВ левом верхнем углу панелиСверху-вниз, в колонкуВ первую строку следующей колонки
Таблица 9.

Обратите внимание, что когда речь идет о строках-колонках, это вовсе не значит, что прямо в момент создания FlowLayoutPanel-и вы должны сказать нечто вроде "это будет панель, разделенная на 3 строки и 4 колонки". Совсем нет. Более того, даже чисто теоретически вы такое сказать не сможете. Все эти строчки-колонки высчитываются динамически, на основании размеров добавляемых control-ов. Поэтому вполне возможна ситуация, когда при развертывании по колонкам первая из них будет содержать, к примеру, четыре контрола, а вторая – только два, т.к. эти два будут по высоте не меньше, чем предыдущие четыре. Разумеется, если добавить, например, 15 совершенно однотипных control-ов, то они будут размещены как на параде, по линеечке. Но и разнотипные элементы будут выстроены настолько “красиво”, насколько это возможно. Разобранные свойства являются, по сути, единственными “достопримечательностями” нового контрола. Он настолько насквозь автоматизирован, что даже не дает поуправлять собой. Хотя нет, еще одну интересную особенность данного control-а не описать невозможно. Дело в том, что любой control, помещенный на поверхность этой панели, приобретает т.н. “внедренное” свойство FlowBreak (тип bool). Внедренным оно называется потому, что control (как класс) на самом деле такого свойства не имеет, а получает его как “подарок” от своего родителя – FlowLayoutPanel, и, разумеется, тут же теряет его, если перестанет быть дочерним control-ом этой панели. Конечно же, это свойство действительно только во время дизайна. Во время исполнения его не существует. Но во время проектирования действительно можно выбрать, допустим, кнопку, лежащую на FlowLayoutPanel, и в PropertyGrid поменять значение этого свойства с используемого по умолчанию false на true. Это будет означать, что данная кнопка завершает текущую строку/колонку, даже если там осталось достаточно места, чтобы вместить очередной добавляемый control, который, соответственно перейдет на следующую строку/колонку. Во время исполнения, повторюсь, такого свойства у control-а, размещенного на панели, не будет, т.к. его нет в классе, представляющем этот control. Зато можно прибегнуть к услугам функции public void SetFlowBreak (Control control, bool value) и добиться того же самого – идентифицировать control, который однозначно и бесповоротно терминирует раскладку по текущему направлению. Кроме того, несмотря на полную автоматизацию процесса размещения элементов, все же можно косвенно воздействовать на него. В этом могут помочь свойства класса Control Padding (заполнение) и Margin (разграничение). Что же такое вообще эти новые свойства Control.Padding и Control.Margin? Для начала отмечу, что оба свойства принимают/возвращают экземпляр одного и того же типа, а именно структуры Padding. Данная структура представляет собой удобную инкапсуляцию информации, связанной с заполнением (padding) и c разграничением (margin) прямоугольных элементов пользовательского интерфейса. Теперь – что такое заполнение? Это некая “мертвая зона” между краем и телом элемента UI. Причем чаще всего это свойство равно нулевой структуре Padding, т.е. такой структуре у которой все 4е поля – Top,Bottom,Right,Left равны 0. Но иногда выгодно образовать с одной (или с нескольких) сторон control-а такую “мертвую зону”. Ну хотя бы с целью тонкого форматирования внутреннего содержимого control-а.


Рисунок 12.

На рисунке 12 показаны четыре абсолютно идентичные кнопки. Единственная разница между ними – значение свойства Padding. Какая именно структура Padding была использована для присваивания этому свойству, показано прямо справа от кнопки. Малиновым цветом выделена образованная в результате такого присвоения зона. Именно она заставляет внутреннее содержимое control-а (текст и картинка в данном случае) быть сдвинутыми относительно их “нормального” положения. Собственно говоря, вот эти малиновые прямоугольники и есть тот самый Padding. Но есть один тонкий момент. Так это свойство работает в случае control-а, который не является контейнером. В случае контейнера все несколько изменится.

Вот как будет выглядеть та же панель FlowLayoutPanel с четырьмя кнопками:


Рисунок 13.

Разница очевидна. В случае кнопки свойство Padding создает “зону отчуждения” между краем control-а и его собственным содержимым. В случае же контейнера та же зона создается между краем контейнера и его дочерними control-ами. Ну а что же по поводу разграничения (margin)? Действие Control.Margin можно увидеть только если данный control окружить другими control-ами. Это свойство установит некую “персональную зону” для данного control-а. Другими словами, это свойство определяет минимальную дистанцию, разделяющую прилегающие края двух соседних элементов пользовательского интерфейса (см. рисунок 14).


Рисунок 14.

Во всех трех случаях на одну и ту же панель помещены 9 кнопок. В первом случае их свойство Margin оставлено в состоянии по умолчанию (по 3 пиксела со всех сторон). Во втором случае указанное свойство изменено у кнопки ‘5’, а в последнем случае – у кнопки ‘7’. Какие именно структуры Padding присвоены этому свойству, обозначено прямо на рисунке. Как видно, Margin действительно помогает установить “персональную зону” вокруг данного control-а. Таким образом, грамотное применение двух разобранных свойств помогает проводить очень тонкий “тюнинг” раскладки добавляемых control-ов. А если еще учесть, что одна FlowLayoutPanel (а равно и TableLayoutPanel) может быть вложена (как дочерняя) в другую подобную панель, то открывающиеся перспективы по организации data-driven форм поистине воодушевляют. Но еще один тонкий момент новой панели остался “за кадром”. Он связан с тем, как работают два свойства (Anchor и Dock) у control-ов, помещенных на рассматриваемую панель. А работают они совершенно не так, как можно предполагать по прошлому опыту размещения их на формах или обычных панелях. Там значение DockStyle.Fill свойства Dock однозначно позволяло control-у оккупировать все доступное пространство родительского контейнера. А заякоривание (anchor) за нижний край контейнера значило, что control будет следовать за этим краем буквально “как привязанный”. :) Все совершенно не так в случае FlowLayoutPanel. Одиночный control никогда не оккупирует, допустим, весь верхний край панели, даже если выставить ему Dock в значение DockStyle.Top. Здесь идея в другом. Как уже говорилось, вся панель динамически (и автоматически) “расчерчивается” на строки-колонки и добавляемые control-ы оказываются в этаких виртуальных ячейках. В случае ориентаций (см. таблицу 9) LeftToRight и RightToLeft ширина ячейки для данного control-а определяется шириной самого control-а, а вот высота – высотой самого высокого control-а в данной строке. При ориентациях TopDown и BottomUp, наоборот, высота ячейки определяется высотой самого control-а, а ширина – шириной самого широкого control-а в данном столбце. И вот после того, как такая ячейка рассчитана, именно ее края становится точками привязки для свойств Anchor и Dock. Т.е. DockStyle.Fill заполнит указанным control-ом всю ячейку, но ни в коем случае не всю панель. Как следствие – самый высокий/широкий в строке или столбце control вообще никак не реагирует на изменение любого из этих двух свойств, т.к. он по определению изначально занимает всю отведенную ему ячейку. Понимаю, что изначально довольно непросто удержать в голове все эти ячейки/столбцы/строки. Поэтому был написан довольно подробный демо-пример к рассмотрению которого я и перейду.

Как это выглядит

Пример к данному разделу запускается кнопкой "FlowLayout Panel" в главной форме. В появляющейся форме находится сама панель, 8 кнопок различных размеров, выпадающий список для управления свойством FlowDirection и флажок для управления свойством WrapContents. Два последних относятся, конечно же, к самой панели и особых пояснений не требуют. Для демонстрации правил докинга и заякоривания дочерних control-ов был предпринят такой шаг: выбраны три кнопки (окрашенные в различные цвета), а также созданы три управляющих панели (цвет заголовка панели совпадает с цветом кнопки, управляемой с этой панели). С помощью этих панелей можно менять оба свойства любой из трех кнопок и видеть результаты этих изменений. Уверяю, что игры с этими панелями прояснят ситуацию гораздо лучше, чем весь последний абзац.


Рисунок 15.

TableLayoutPanel

Второй панелью для создания data-driven Windows Forms является TableLayoutPanel. Идея ее весьма схожа с предыдущей панелью – те же расчерченные строки и колонки. Но в данном случае характеристика “расчерченные” может быть употреблена безо всяких кавычек и с полным на то основанием. Если в предыдущем варианте все эти колонки-строки носили виртуальный характер, и таблица могла содержать 4 строки в первой колонке, 2 – во второй, 5 – в третьей и т.п., то TableLayoutPanel подобный “разброд и шатание” исключает по определению. В данном случае имеется четко разграфленная сетка наподобие таблицы Excel. Только в случае Excel в ячейки помещаются символы и цифры, а в панель, разумеется, помещаются control-ы и контейнеры (Любые, в т.ч. другие панели FlowLayout и TableLayout). Помещать их туда можно и в дизайн-тайме, но, конечно, прежде всего мы будем помещать их во время исполнения (мы же разрабатываем data-driven форму). Начнем же изучение этой панели с первого шага – перетащим ее в дизайнере из тулбокса и бросим на форму:


Рисунок 16.

На рисунке 16 заготовка панели показана с раскрытым “Умным ярлыком”. Заготовка представляет из себя таблицу размерности 2x2. “Умный ярлык” позволяет добавлять и удалять строки/колонки (первые четыре пункта меню). Пункт “Edit Rows and Columns…” открывает дополнительный диалог настройки размеров строк/колонок.

Класс TableLayoutPanel имеет 2 свойства – ColumnStyles и RowStyles. Первое возвращает коллекцию TableLayoutColumnStyleCollection, а второе – TableLayoutRowStyleCollection. Обе коллекции являются чуть ли не копией друг друга (по внутреннему устройству, а не по содержанию) и унаследованы от одного базового абстрактного класса TableLayoutStyleCollection. Коллекция, посвященная колонкам, содержит набор элементов ColumnStyle, строкам – RowStyle. И, опять же, оба этих типа крайне похожи друг на друга и наследуются от одного предка – TableLayoutStyle. Таким образом, каждая колонка добавляет очередной ColumnStyle в TableLayoutColumnStyleCollection. То же касается строк. Дополнительный диалог, речь о котором шла выше - не более, чем удобный способ изменять свойства каждого элемента ColumnStyle/RowStyle из обеих коллекций (рисунок 17).


Рисунок 17.

Выпадающий список позволяет выбрать, с чем работать – с колонками или строками (фактически, одну из двух коллекций). Затем выбирается конкретный элемент данной коллекции. Три переключателя справа служат для задания значений свойств Width для ColumnStyle, и Height для RowStyle (оба - типа float). Первое задает ширину колонки, а второе – высоту строки. В чем именно будет измеряться эта высота/ширина, определяется еще одним свойством, имеющимся у обоих элементов – SizeType. Это свойство может принять одно из трех значений одноименного перечисления, которым и соответствуют три упомянутых переключателя: Absolute, AutoSize, Percent. Как происходит распределение пространства, и как на это влияет свойство SizeType, я объясню на примере колонок. Если всю ширину TableLayoutPanel надо распределить между, допустим, пятью колонками, первым делом отбираются все колонки, у которых SizeType выставлено в Absolute. В этом случае у отобранных колонок свойство Width рассматривается как заданное в пикселах, и каждой из этих колонок выделяется запрошенная ширина. Все значения Width колонок из предыдущего предложения суммируются и вычитаются из общей ширины панели. Далее отбираются те колонки, у которых SizeType выставлено в AutoSize. Здесь уже свойство колонок Width попросту игнорируется (обратили внимание, что радиокнопка “AutoSize” не имеет ассоциированного поля ввода?), а ширина колонки определяется “по содержимому“. А проще – широчайший control, содержащийся в колонке, определит ее ширину. Найденная ширина (или их сумма, если подобных колонок было несколько) опять-таки вычитается из остатка общей ширины панели. И вот та ширина, что остается в результате этих двух вычитаний, распределяется между оставшимися колонками. У них SizeType будет выставлено в Percent, а свойство Width - рассматриваться как заданное в процентах. Именно требуемое количество процентов каждая из таких колонок и получит. Необходимо еще раз подчеркнуть: требуемые проценты отсчитываются не от полной ширины панели, а от того остатка, что образовался после двух вычитаний, упомянутых ранее. В случае строк действия и последовательность расчета абсолютно аналогичны, только определяющей становится не ширина, а высота панели. Указанная цепочка расчетов повторяется при каждом добавлении/изъятии control-а. Поэтому при достаточно большой таблице (скажем, 10x10 и более) новый control добавляется с заметной задержкой даже на самом современном компьютере. Что поделать – неизбежная плата за автоматизацию процесса раскладки control-ов. Как показано на рисунке 17, две “стартовых” колонки (а равно и строки) изначально имеют размерность 50 процентов. А добавляемые из того же диалога кнопками Add/Insert новые строки/колонки будут иметь размерность 20, причем пикселов. Кстати, задание размера в пикселах/процентах возможно не только в диалоге, показанном на рисунке. Вы можете “схватится” за границу колонки/строки в дизайнере и попросту раздвинуть/сжать ее. Но если нужна, допустим, колонка шириной 120 пикселов ровно, использование диалога предпочтительнее. Помимо этого, вы не сможете визуально (и вообще как-либо) менять высоту/ширину тех строк/колонок, SizeType которых выставлено в AutoSize. И это вполне логично. Вы ведь в этом случае хотите, что бы таблица сама нашла верный размер! Как вы помните, рассказывая о предыдущей панели, FlowLayout, я говорил, что в момент дизайна сказать фразу "это будет панель, разделенная на 3 строки и 4 колонки", вы не можете. В случае TableLayoutPanel вы не только можете сказать эту фразу, но даже более того, панель просто-таки поощряет вас сделать это. Таким образом, панель FlowLayoutPanel явно рассчитана на использование во время исполнения, а TableLayoutPanel больше тяготеет к использованию в процессе разработки. Хотя, конечно, это не значит, что создав таблицу 3x4, вы в дальнейшем в своем коде не сможете ее расширить. Сможете, но…. Впрочем, обо всем по порядку. Если вы знаете, что вам нужна именно таблица 3x4 (и никакая другая), то правильным шагом будет задать свойству RowCount значение 3, а свойству ColumnCount – 4 (оба свойства имеют тип int). Чтобы жестко зафиксировать размеры таблицы, необходим дополнительный шаг – задание значения TableLayoutPanelGrowStyle.FixedSize свойства GrowStyle (принимает одно из значений перечисления TableLayoutPanelGrowStyle). При этом фиксируется число колонок и строк, но не их размер. Строки можно сделать высокими, низкими (через RowStyle и его свойства Height и SizeType), но их число будет постоянным – 3. Так же и колонок будет всегда 4. А что случится, если все же попытаться добавить тринадцатый control на панель? Ничего хорошего, уверяю вас. :) На самом деле просто будет сгенерировано исключение типа ArgumentException с сообщением о невозможности добавления. Теперь несколько иной сценарий. Известно, что нужна как минимум таблица 3x4. Но при этом, не исключено (или даже определенно), что в зависимости от неких условий во время исполнения потребуется большая размерность по одному или другому измерению (или даже по обоим). Тут ситуация меняется. Для начала, однозначно не следует выставлять GrowStyle в TableLayoutPanelGrowStyle.FixedSize. Вместо этого нужно воспользоваться двумя другими значениями перечисления TableLayoutPanelGrowStyle: AddColumns или AddRows (это значение по умолчанию). Если оставить значение AddRows по умолчанию, тринадцатый control-а попадет в первую колонку новой строки. А в случае AddColumns будет добавлена новая колонка, но новый control в нее, как ни странно, не попадет. :) Дело вот в чем. Каждый размещаемый на TableLayoutPanel control приобретает пару внедренных (помните что это такое?) свойств: Column и Row. Эти свойства содержат координаты ячейки, в которой размещен данный control. Например, когда в дизайнере вы бросаете кнопку в третью строку второй колонки, эти свойства кнопки автоматически получают значения Row=2, Column=1 (верхняя левая ячейка находится в нулевой строке и нулевой же колонке). Теперь эта кнопка надежно связана с указанной ячейкой и никуда из нее не денется. Совсем не то же самое происходит во время исполнения. Новый control (в общем случае) добавляется строчкой кода:

this._tableLP.Controls.Add(newButton); //_tableLP - это TableLayoutPanel

Это значит, что оба упомянутых свойства указанного newButton имеютзначение -1, что, в свою очередь, означает “плавающий” control. Иначе говоря, данный control будет стремиться занять ячейку в как можно более высокой строке (т.е. желательно с индексом 0, если там все занято - 1 и т.д.) и как можно более левой колонке (опять же, если есть возможность, в колонке с индексом 0, потом 1 и т.д.). Таким образом, при добавлении новой колонки начинается массовая “миграция” control-ов, :) так как появляется возможность оккупировать ячейку в строке с индексом 0, 1 и т.д. Причем уже лежащие на панели control-ы имеют преимущество перед вновь добавляемыми, благодаря которым, собственно, эти новые возможности размещения у них и появились. Теперь представьте, что мы уже имеем полностью заполненную таблицу размером 3x4, причем оба рассматриваемых свойства всех control-ов имеют значение -1. Если добавить тринадцатый control, который, в свою очередь, добавляет пятую колонку, начнется упомянутая миграция – control из первой колонки и второй строки займет верхнюю ячейку вновь образованной колонки, вся вторая строка сдвинется на одну ячейку влево и т.д. Желающим проверить свое “пространственное мышление” :) предлагаю на время прерваться и попытаться мысленно представить себе весь этот путь миграции, чтобы ответить на простой вопрос – где же окажется добавленный control? Уверяю, что это самый настоящий этюд для программистов :) хоть и не из разряда зубодробительных. Для остальных же дам ответ: он окажется в третьей строке и третьей колонке, а две ячейки справа от него будут пустыми – туда попадут четырнадцатый и четырнадцатый control-ы. Видимо, авторы control-а предвидели, что желающих решать подобные “шарады” в уме найдется немного, и предусмотрительно ввели упомянутый выше механизм “привязки” данного control-а к данной ячейке. Как этот механизм используется во время разработки, уже рассмотривалось. А как воспользоваться им во время исполнения? Тут есть два пути. Первый пригодится, если новый control добавляется строчкой кода, приведенной выше. Как вы помните, в этом случае оба этих свойства выставлены в -1. Но ситуацию можно поправить. Функции public void SetColumn (Control control, int column) и public void SetRow (Control control, int row) позволяют в любой момент после добавления control-а “привязать” его к колонке и/или строке. Кстати, ими же можно воспользоваться для перебрасывания control-а с одного места дислокации в другое. Второй путь даже более эффективен и лаконичен. Дело в том, что свойство Controls у TableLayoutPanel не унаследовано от класса Control. Это новое свойство, просто имеющее то же самое название. Оно возвращает не коллекцию Control.ControlCollection (как в Control-е), а коллекцию TableLayoutControlCollection. Эта коллекция примечательна тем, что вдобавок к тому методу Add, что уже был рассмотрен, имеет и перегруженную версию:

this._tableLP.Controls.Add(newButton, 2, 4); //_tableLP – TableLayoutPanel

При таком варианте newButton окажется в третьей колонке пятой строки и будет там зафиксирован. Одним словом, это самый простой метод задать значения свойств Column и Row дочернего control-а. Кстати, я надеюсь, вы понимаете, что во всем предыдущем абзаце об этих двух свойствах дочерних control-ов говорилось как о реальных свойствах лишь для краткости. На самом деле этих свойств, конечно же, у них нет и быть не может. В реальности сама TableLayoutPanel содержит внутреннюю таблицу вида: “Название control-а” - “Привязан к колонке N….” - “Привязан к строке N….”, и эти якобы “свойства” – всего лишь значения из второго и третьего поля этой таблицы. И еще один момент. Как вы уже поняли, свойство GrowStyle панели чрезвычайно важно для размещения новых control-ов. Но оно вступает в игру только когда все ячейки таблицы уже заполнены. Пока есть хоть одна свободная ячейка, добавляемый control будет размещаться именно в ней.

Еще два интересных внедренных свойства, получаемых любым control-ом, размещенным в TableLayoutPanel: ColumnSpan и RowSpan. Доступ к ним можно получить путем выбора конкретного control-а на поверхности панели и последующего обращения к браузеру свойств. Оба свойства имеют тип int и изначально равны 1. Если приравнять ColumnSpan двум, это будет означать, что control “забирает” под себя две ячейки в текущей строке (т.е. 2 ячейки по горизонтали), а если приравнять RowSpan трем, то данный control отхватит 3 ячейки по вертикали. Важнейшим следствием из такого “разворачивания” на несколько колонок/строк является тот факт, что control начинает рассматривать все оккупированные им ячейки как одну большую ячейку. Соответственно, докирование и заякоривание идет в привязке к краям этой большой “виртуальной” ячейки. В о время исполнения для подобного “разворачивания” control-а по строкам/колонкам можно использовать функции public void SetRowSpan (Control control, int value) и public void SetColumnSpan (Control control, int value), соответственно. И раз уж речь зашла о докировании и заякоривании, разберу и их. Собственно, идея полностью перенесена из предыдущей панели. Для этих двух операций направляющими служат края ячейки (или нескольких, если ColumnSpan/RowSpan больше единицы), а не родительской панели. Но поскольку здесь ячейки гораздо более четко очерчены, то и результаты гораздо более наглядны и предсказуемы. В примере вы сможете опробовать и то, и другое, что называется, “вживую”. А для большей наглядности рекомендую воспользоваться свойством CellBorderStyle панели. Это свойство принимает одно из значений перечисления TableLayoutPanelCellBorderStyle, и если изменить его с используемого по умолчанию None на любое другое, можно убедиться, что все предыдущие рассуждения о расчерчивании таблицы на ячейки – отнюдь не пустой звук. Ячейки вполне реальны, и их даже можно увидеть воочию. Иными словами, данное свойство определяет как визуально будет выглядеть границы ячеек. Это упрощает понимание сути тех двух операций, речь о которых велась чуть выше. Например установив заякоривание дочернего control-а по левому и правому краю, можно увидеть как он растянулся от почти левой границы ячейки до почти правой. Почему “почти”, а не ровно от границы до границы? А тут уже начинают играть роль разобранные в предыдущем разделе заполнение (padding) и разграничение (margin). Зазор оставшийся между левой границей ячейки и левой гранью дочернего control-а равняется сумме значений свойств Padding панели и свойства Margin дочернего control-а, а уж совсем точно говоря – сумме членов Padding.Left тех двух структур, что будут возвращены этими двумя свойствами. То же самое можно сказать о правых краях и членах Padding.Right. По умолчанию Padding панели равен 0 по всем измерениям, а Margin дочернего control-а (любого) равен 3, и тоже по всем измерениям. Вот отсюда и берется “почти” – зазор по три пиксела с каждой из сторон. И обратите внимание еще вот на что. Когда вы бросаете дочерний contro на панель в дизайнере, свойство Anchor у него автоматически принимает значение AnchorStyles.None, и control позиционируется ровно по центру ячейки. Это не совсем обычное поведение. Привычнее, когда добавляемые control-ы “цепляются” за левую и верхнюю границу своего родителя. При добавлении во время исполнения так и будет – вновь добавленный control разместится в верхнем-левом углу ячейки. Тем не менее в дизайнере решили сделать вот так. Ничего страшного, конечно, хотя и не слишком последовательно.

Как это выглядит

Пример к данному разделу запускается кнопкой "TableLayout Panel" в главной форме. В появившейся форме находится сама панель, 5 кнопок различных размеров (изначально; затем вы сможете динамически добавить новые) как примеры дочерних control-ов и различные вспомогательные элементы для управления свойствами панели, а также для докирования и заякоривания кнопки с синим фоном. Особый интерес представляет кнопка, добавляющая новые дочерние control-ы (я избрал в качестве таковых кнопки) на панель во время исполнения. Попробуйте использовать ее с различными значениями свойства GrowStyle. По желанию можно уменьшать/увеличивать размеры самой формы. TableLayoutPanel сама заякорена к ней, так что увеличивая в размерах форму, вы будете менять и размер таблицы, что позволит более детально исследовать ее поведение при наполнении все большим и большим количеством дочерних control-ов.


Рисунок 18.

На что обратить внимание

В ходе исследований обе панели показали себя как вполне “зрелые” control-ы - никаких особых хлопот не доставляли и действовали в строгом соответствии с документацией. Но один момент, связанный с TableLayoutPanel, меня крайне удивил. Как вы помните, каждая строка и колонка в нем являются элементами коллекций TableLayoutRowStyleCollection и TableLayoutColumnStyleCollection. Через эти коллекции производится обращение к конкретной строке или колонке, что дает возможность изменить ее свойства, размеры и т.д. Если изначально таблица имела размер 2x2 (т.е. обе коллекции содержали по 2 элемента), то после добавления пятого дочернего control-а, вызывающего добавление, допустим, новой колонки, логично было бы ожидать, что в TableLayoutColumnStyleCollection появится третий элемент. Но этого не происходит! Т.е. новая колонка явно появилась, и даже уже принимает в себя дочерние control-ы, но ни коллекция колонок, ни даже их счетчик (свойство ColumnCount) никак на это событие не отреагировали. Они оба продолжают сообщать, что таблица по-прежнему состоит только из двух колонок. Все ровно то же самое касается и коллекции строк, и их счетчика (свойство RowCount). Насколько это баг бета-версии, а насколько – запланированное поведение, разобраться непросто. Но, по крайней мере, автор данного опуса решительно отказывается понимать такую логику поведения вроде бы до конца продуманного и грамотно спроектированного control-а.

BackgroundWorker Object

Значение многопоточной (multithreading) технологии в OS Windows вообще и в .NET-разработках в частности трудно переоценить. Целый ряд приложений без ее использования был бы куда как менее “отзывчивым” при долгосрочных операциях, а часть приложений могут быть реализованы только с ее помощью. Это еще без учета того, что Windows как операционная система решительно не состоялась бы, не будь этой технологии. Ведь второй DOS нам не нужен, верно? :) Поэтому уверенное владение данной темой должно стать чуть ли не визитной карточкой любого уважающего себя прикладного разработчика. А теперь плохие новости. Трудности разработки, тестирования и поддержки многопоточного приложения превосходят аналогичные действия для однопоточного приложения чуть ли не в разы, а в особо экстремальных условиях так и на порядок. Например MSDN честно предупреждает:

Использование многопоточной технологии в любом ее виде может привести к очень серьезным и сложным “багам” в вашем коде. Проконсультируйтесь с Threading Design Guidelines перед реализацией любого решения, использующего эту технологию.

И, будем честны, краски здесь отнюдь не сгущены – все так и есть. Поэтому многие сходятся на том, что первый вопрос при разработке многопоточного приложения должен быть: “А без многопоточности здесь действительно обойтись нельзя?” И уж только после решительного ответа “нет” переходить к боевым действиям. Но есть целый класс задач, где ответ на этот вопрос очевиден на 99%. Общими словами этот класс можно описать так: есть форма, запускающая “долгоиграющую” процедуру (расчета, извлечения данных, упаковки базы, распаковки архива и т.п.). В этом случае однопоточный вариант практически исключен – вы же не хотите, чтобы на время подобной операции форма перестала реагировать на какие-либо действия пользователя? Вот и я думаю, что ему надо позволить элементарно перемещать форму, минимизировать, закрывать ее и вообще жить полноценной жизнью. Некоторые читатели наверняка возразят автору – ну зачем же приплетать сюда многопоточность со всеми ее заморочками, когда для поддержания формы в активном состоянии достаточно в цикле “долгоиграющей” процедуры не забывать вызывать Application.DoEvents(), и все? На это можно ответить, что по поводу многопоточности эти читатели правы, а по поводу DoEvents – не совсем. Данная функция напоминает мне старый (но не добрый) Windows 3.x с его кооперативной многозадачностью. Дело в том, что DoEvents выполняется в том же потоке, что и цикл “долгоиграющей” процедуры (а вызывается именно из цикла). Поэтому отзывчивость формы в данном случае – не более, чем оптический обман. :) Ведь чудес не бывает, и один и тот же поток может в каждый квант времени работать либо с процедурой, либо с пользователем. Но не одновременно! Не верите? На дружественном сайте http://www.gotdotnet.ru есть неплохой пример по заполнению списка. Если его запустить, то КАЖЕТСЯ, что все OK. Элементы добавляются, кнопки нажимаются… что еще надо? Ключевое слово здесь – кажется. Все выглядит вполне невинно только потому, что каждый элемент добавляется почти мгновенно и столь же мгновенно DoEvents обрабатывает сообщения из очереди. А теперь представьте, что каждый элемент не генерируется в цикле, а извлекается из достаточно загруженного сервера БД и только после этого добавляется в список. Для эмуляции этого можете в цикл примера добавить нечто вроде

Thread.Sleep(900);

Ну как? Отзывчивости поубавилось? Еще один вариант – слишком долгое обрабатываемое сообщение. Здесь даже никаких эмуляций не надо. Пусть элементы в список по-прежнему добавляются мгновенно. Но в процессе добавления элементов ухватите форму за заголовок и, удерживая левую кнопку мыши, поводите рамкой окна по экрану. Добавление элементов остановилось! А все потому, что DoEvents – синхронный метод. Не кажется ли вам, что на дворе XXI век, и решение передвинуть окно из левого верхнего угла монитора в правое нижнее – это не повод прекращать основную работу? Тем более, что за те 2-3 секунды, что пользователь тащит окно, эта самая работа могла бы быть завершена. Одним словом, автор вполне разделяет точку зрения, изложенную в статье, описывающей, кстати, и другие недостатки DoEvents.

Итак, DoEvents – это не более, чем решение проблем путем внесения новых. Поэтому я и взял на себя смелость утверждать, что для упомянутого класса задач в 99% необходима реальная многопоточность. Возможно, в 1% случаев сработает решение и с DoEvents, хотя я бы рисковать не стал. Тем более, что во второй версии .NET Framework появился такой замечательный класс, как BackgroundWorker. Он дает возможность даже начинающим программистам почувствовать себя настоящими зубрами многопоточной архитектуры, прежде всего благодаря тому, что сводит к минимуму вероятность тех самых “очень серьезных и сложных багов”. Достаточно соблюсти несколько несложных правил, а о прочем позаботится новый класс. К его рассмотрению я и перейду.

Постановка задачи и общая архитектура решения

Я решил описать новый класс в рамках решения более-менее реальной задачи, где его применение не только оправданно, но и практически неизбежно. Итак пусть у нас есть достаточно загруженный сервер БД, на котором хранятся цифровые изображения. Требуется извлечь определенное количество этих изображений, показать пользователю в режиме слайд-шоу, динамически отобразить, какой по счету рисунок ему показывается в текущий момент времени и сколько еще осталось, а также предоставить пользователю возможность в любой момент времени отменить весь процесс и вернуться в начальное состояние. Эту задачу можно решить как минимум тремя различными путями (DoEvents даже не рассматриваем). Назову эти пути Unsafe, Safe, BW (от BackgroundWorker). Независимо от выбранного пути общая архитектура будет базироваться на четырех методах:

Как вы понимаете, первый и четвертый методы вызываются лишь по разу в начале и конце задания, соответственно, а оставшиеся два – на каждой итерации основного цикла. Также обратите внимание, что все методы, кроме третьего, манипулируют теми или иными control-ами на форме. А это значит, что рабочий поток не может вызывать их напрямую. Необходим маршаллинг вызовов этих методов в соответствующий поток (т.н. UI-thread). Помните золотое правило многопоточности в приложении к WindowsForm? “Ты должен оперировать с формой/control-ом/окном только из потока, где они созданы”. Правда, есть четыре метода и одно свойство, принадлежащие классу Control и безопасные для вызова из абсолютно любого потока: InvokeRequired (свойство) Invoke, BeginInvoke, EndInvoke, CreateGraphics (методы). По отношению к любым другим методам/свойствам это правило должно применяться разработчиком автоматически. А что бывает, если это правило нарушить, показывает первый путь – Unsafe. В этом варианте создается новый (рабочий) поток, и в качестве задания ему назначается метод DoAllWorkUnsafe. Этот метод начинает вызывать все четыре базовых метода “в лоб”, не считаясь ни с какими правилами. К чему это приводит? Как ни странно это прозвучит, но ответ на этот вопрос зависит от способа запуска нашего приложения. Если оно запускается из-под VS2005 по F5, то встроенный отладчик улавливает “игру не по правилам” и тут же генерирует InvalidOperationException. Т.е. в совсем элементарном случае (текстовое окно на форме и что-то типа следующего в рабочем потоке):

this.textBox1.Text = "This text was set unsafely.";

отладчик на запуск подобного безобразия реагирует так:


И это очень хорошо! Поскольку здесь мы имеем тот редкий случай, когда сухая теория обходит практику корпуса на полтора. Если из-за нарушения золотого правила что-то пойдет не так, то обычными методами отладки и скрупулезного тестирования отловить подобных “жучков” практически невозможно. Поэтому этот момент и это правило невозможно изучить в “полевых” условиях, на практике, его надо просто знать и помнить. А вот забывчивым отладчик любезно напомнит об этом. Но что будет, если мы запустим тот же файл не из студии, а напрямую из Explorer-а? Ну, тут отладчик будет бессилен, и задача, скорей всего, отработает нормально. Но ценность правила от этого только возрастает. Дело в том, что задача отработает нормально в первый раз, во второй и даже сто второй, а на сто третий тихо умрет с подвешиванием всего UI. Причем если потом снова ее запустить, она вновь сто раз отработает без звука. В этом-то и заключается коварство многопоточности – плавающие, не повторяющиеся или редко повторяющиеся ошибки. Как говорится, добро пожаловать в ад. Например, при запуске (из Explorer-а) на моей машине демо-примера в Unsafe-режиме первый запуск всегда проходит нормально. При втором запуске тоже все нормально, но с одним исключением – начисто пропадает индикатор прогресса, который иногда проявляется вновь, если сделать еще запусков 10-15 в этом режиме. А чаще всего не проявляется вовсе.

Описанное выше поведение с генерацией исключения при нелегальном обращении к контролу на самом деле контролируется публичным, статическим свойством Control.CheckForIllegalCrossThreadCalls (тип bool). По умолчанию оно равно true, что и приводит к показанным выше последствиям. Вы можете в любой момент (например в конструкторе главной формы) выставить его в false. Это отменит генерацию исключения InvalidOperationException и позволит вам работать даже с “неправильным” кодом. Но я рекомендую прибегать к такому средству только для исследовательско-образовательных целей и ни в коем случае не допускать такого безобразия в промышленном коде.

Хотя это нигде и не афишируется у любого Windows.Forms контрола есть часть (очень не многочисленная, заметим для справедливости) свойств к которым не смотря на все угрозы и проклятия MSDN и прочих источников информации все же безопасно обращаться из любого потока напрямую, минуя InvokeRequired / Invoke. К таковым относятся BackColor, ForeColor, BackgroundImage и ряд других. Вы можете убедиться в этом заменив Text у textBox1 из примера выше на, допустим, BackColor. Никаких предупреждений отладчика не будет! А объясняется это просто – данные немногочисленные свойства в своем коде НЕ обращаются к хэндлу (Control.Handle) контрола. А именно такое обращение является “смертельным” для связки многопоточная технология + Windows.Forms. Тем не менее все изложенное в данном параграфе стоит воспринимать как забавные факты из устройства Фреймворка и продолжать действовать по железному правилу: “обращаться к любому контролу вне зависимости от конкретного свойства/метода ИСКЛЮЧИТЕЛЬНО из нужного потока”.

Хорошо, но что же делать после получения подобного предупреждения? Ну, как минимум перейти к Safe-варианту. В нем новый поток исполняет метод DoAllWorkSafe. В отличие от своего “коллеги”, DoAllWorkUnsafe, он осведомлен о правилах хорошего тона, и поэтому перед вызовом трех методов, оперирующих control-ами формы, применяет известный шаблон вызова: сначала посредством свойства InvokeRequired узнае, находится ли он в “правильной” нити. Если да – хорошо, производится обычный вызов метода. Если нет – создается делегат, указывающий на ту же самую функцию, и передается методу Invoke. Последний исполняет этот делегат в UI-thread, что и требовалось. Оставшийся метод (TakeNextImage) подобной “оберткой” не обременен, он только “достает” изображения и с пользовательским интерфейсом не взаимодействует. Разумеется, этот вариант работает абсолютно стабильно, независимо от способа и количества запусков. Кстати, говоря об организации и работе с новыми потоками, нельзя не отметить еще одно приятное нововведение версии 2.0. Дело в том, что раньше на форумах довольно часто возникал вопрос: “Я с помощью нового потока запускаю метод DoLongWork. Но мне надо передать параметр (чаще несколько) ему на вход! Как быть?”. Ну и соответствующие ответы, вроде организации глобального класса со статическими членами и передачи параметров через них. Теперь время таких… г-мм… неоднозначных :) архитектурных решений позади. Вторая версия предлагает перегруженный конструктор класса Thread:

public Thread(ParameterizedThreadStart start);

Аргументом этой перегрузки является делегат, представляющий метод, принимающий на вход единственный параметр типа object. Таким образом, теперь DoLongWork может вполне “легально” иметь один аргумент. Ну а уж упаковать в этот единственный аргумент требуемое количество параметров – вопрос чисто технический. Парная перегрузка появилась и у метода Thread.Start:

public void Start(object parameter);

Именно здесь указывается конкретное значение аргумента, если он один, либо экземпляр того класса, в котором упаковано два или больше аргументов. Метод Start запустит DoLongWork и передаст ему на вход параметр parameter. По такой схеме в моем примере методам DoAllWorkSafe и DoAllWorkUnsafe передается единственный параметр типа int – количество рисунков, подлежащих извлечению из БД. Итак, реализован второй вариант решения задачи, уже вполне рабочий. Но назвать такую реализацию элементарной вряд ли возможно. Надо помнить, что каждый метод, обращающийся к UI, должен быть “обернут”, для такой обертки каждому методу требуется персональный делегат и т.д. Все меняется с появлением класса BackgroundWorker. Именно с его помощью реализован третий вариант решения BW. Для начала работы с указанным классом можно, конечно, вызвать его конструктор

new System.ComponentModel.BackgroundWorker();

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

public void RunWorkerAsync();

на объекте нашего класса. Этот метод создает новый поток. Когда он готов к работе, срабатывает событие DoWork, на которое можно подписаться. Именно в его обработчике и происходит длительная фоновая работа. Если эта работа настолько продолжительна, что нужно быть в курсе ее продвижения к конечному результату, можно подписаться на второе событие – ProgressChanged. Наконец, о завершение фоновой работы сообщает третье событие – RunWorkerCompleted. Итак, все взаимодействие кода с новым классом сводится, по большому счету, к подписке на три указанных события. При этом золотое правило многопоточности, о котором говорилось выше, несколько видоизменяется и звучит так: “Код внутри обработчика события DoWork исполняется в отдельной рабочей нити и не должен пытаться получить доступ к элементам пользовательского интерфейса. Обработчики же двух прочих событий ProgressChanged и RunWorkerCompleted исполняются в основной, UI-нити и могут свободно к этим элементам обращаться”. Собственно, это все. Никаких оберток и хитроумных делегатов, просто три события, каждое из которых занимается строго своим делом. Достаточно лишь помнить о видоизмененном правиле, чтобы полностью застраховаться от проблем обращения к UI-элементам из “неправильных” потоков. Кстати, VS 2005 настолько любезна, что при двойном щелчке по событию DoWork в браузере свойств генерируемый ею шаблон обработчика фактически повторяет для вас это правило:

private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
  // This method will run on a thread other than the UI thread.
  // Be sure not to manipulate any Windows Forms controls created
  // on the UI thread from this method.
}
ПРИМЕЧАНИЕ

В комментарии сказано: Этот метод будет выполняться в потоке, отличном от UI-потока. Не манипулируйте напрямую элементами управления Windows Forms, созданными в UI-потоке. – прим.ред.

Теперь, зная о роли каждого события, можно рассмотреть подробнее вторые аргументы их обработчиков (в первом аргументе всех трех событий передается тот экземпляр класса BackgroundWorker, к которому относится событие). Начну с DoWork. Как видно из куска приведенного выше кода, обработчик этого события получает в качестве второго аргумента экземпляр класса DoWorkEventArgs. Что интересного он может сообщить? Ну, во-первых, это, конечно же, свойство Argument этого класса (только для чтения, тип object). Дело в том, что при использовании нового класса BackgroundWorker вновь возникает старая проблема – а что, если фоновому процессу нужно передать параметр на вход? А вот тогда для запуска такого процесса перегруженным можно воспользоваться вариантом упомянутого метода RunWorkerAsync:

public void RunWorkerAsync(object argument);

В параметре argument находится требуемый для передачи параметр. Именно его получит обработчик события DoWork, обратившись к рассматриваемому свойству Argument. В примере я с помощью этого перегруженного варианта метода RunWorkerAsync передаю в обработчик события DoWork экземпляр своего собственного вспомогательного класса DoWorkArgs, назначение которого - быть контейнером четырех параметров, необходимых обработчику для корректной работы. Второе свойство класса DoWorkEventArgsCancel (тип bool). Оно устанавливается в true, как следствие требования главной UI-нити прервать фоновый процесс. Обратите внимание, что присвоение этому свойству любого значения не прерывает выполнения фоновой задачи как такового. Впрочем, схема “прерывание рабочей нити по запросу” в свое время будет рассмотрена. Пока же достаточно запомнить, что назначение свойства Cancel чисто информационное – дать знать обработчику события RunWorkerCompleted, что фоновый процесс работу свою не закончил, и что доверять результатам этой работы нельзя. Да, все три события работают в теснейшей кооперации и обмениваются разнообразными сообщениями наподобие рассмотренного. И третье, последнее свойство класса DoWorkEventArgs Result (тип object) – также является передаточным. Если фоновый процесс не был прерван и завершил свою работу, и результатом этой работы является некое число, объект и т.п., то этот результат помещается в данное свойство и позже становится доступным в обработчике события RunWorkerCompleted. К примеру, в своем примере я таким образом передаю константное число 77. Это, конечно, сделано с чисто демонстрационными намерениями и никакой смысловой нагрузки не несет. В реальном же приложении можно было бы таким образом сообщить общий размер (в байтах) показанных рисунков, общее время (в секундах), затраченное на извлечение их из БД и т.п. Излишне говорить, что использование свойства Result не обязательно. Вполне можно оставить его в \состоянии по умолчанию (null). Это вовсе не будет означать, что фоновый процесс потерпел неудачу, был прерван в результате возникновения исключения или же самим пользователем. Факт прерывания процесса пользователем или наличия исключения выясняется в обработчике события RunWorkerCompleted через специальные свойства (см. ниже). Равенство свойства Result значению по умолчанию не несет ровным счетом никакой информации об общем успехе или неудаче рабочего процесса.

Второе из тройки событий – ProgressChanged. Когда можно ожидать генерации этого события? С DoWork все ясно, оно генерируется сразу после окончания работы метода RunWorkerAsync. Выясняется, что ProgressChanged – это событие, генерация которого полностью зависит от разработчика. Последний, намереваясь его использовать, должен, во-первых, в подготовительной фазе присвоить значение true свойству WorkerReportsProgress (тип bool; по умолчанию false) своего объекта BackgroundWorker. Это в принципе делает возможным использование данного события. Во-вторых, он должен подписаться на него. И в-третьих, он наконец-то имеет право в обработчике события DoWork вызвать метод

public void ReportProgress(int percentProgress);

Именно вызов этого метода и сгенерирует событие ProgressChanged, позволяя коду обновить строку статуса, сдвинуть на шаг индикатор прогресс или иным способом дать понять пользователю, что его ожидания не напрасны. :) Как видите, в качестве аргумента можно передать целое число (в диапазоне 0-100), символизирующее текущий процент выполнения. Опять же, в примере я пользуюсь перегруженным вариантом данного метода:

public void ReportProgress(int percentProgress, object userState);

поскольку хочу, чтобы обработчик события ProgressChanged не только показал текущий статус выполнения, но и отобразил очередной извлеченный из БД “рисунок”. Для этого я передаю в качестве второго параметра экземпляр уже упомянутого вспомогательного класса DoWorkArgs. А уже в нем содержится вся необходимая для корректного отображения информация, и “рисунок” в том числе. Как вы уже догадались, и аргумент percentProgress, и аргумент userState самым прямым путем направляются обработчику события ProgressChanged, в котором успешно извлекаются через переданный ему вторым аргументом экземпляр класса ProgressChangedEventArgs. А именно: аргумент percentProgress получается обращением к свойству ProgressPercentage (тип int), а аргумент userState – к свойству UserState (тип object). Оба свойства доступны только для чтения, что более, чем логично. Больше ничего интересного класс ProgressChangedEventArgs сообщить не может.

Осталось последнее из “большой тройки” событий – RunWorkerCompleted. Когда можно ожидать встречи с ним? Опять принудительный вызов через какой-нибудь метод класса BackgroundWorker? Не всегда. Здесь вообще все несколько сложнее, чем было с предыдущими двумя событиями. RunWorkerCompleted может быть сгенерировано в трех случаях:

Что произошло на самом деле, выясняется через свойства переданного вторым аргументом в обработчик экземпляра класса RunWorkerCompletedEventArgs. Но здесь важно не только обратится к нужному свойству, но и сделать это в правильной последовательности. Итак, прямо в одной из первых строк обработчика нужно обратиться к свойству указанного класса Error (тип Exception). Если оно не равно null, то налицо третий вариант завершения фоновой задачи – по необработанному прерыванию. При этом, обратите внимание, объект класса BackgroundWorker самостоятельно перехватит не перехваченное исключение и не даст ему терминировать все приложение. Вместо этого он просто сообщит о факте возникновения исключения через рассматриваемое свойство. А уж забота не забыть поинтересоваться предоставленной информацией полностью лежит на разработчике. Если указанное свойство вернуло не null, нужно предпринять необходимые шаги для информирования пользователя и для возврата UI к состоянию “до старта”. Если же сравнение с null вернуло истину, выполняется второе сравнение: не равно ли другое свойство – Cancelled (тип bool) – истине? Если это так, налицо второй вариант – завершение по нажатой кнопке Cancel. Опять же нужно проинформировать пользователя и вернуть UI в исходное состояние. И только продравшись через оба этих сравнения, можно рассмотреть первый вариант завершения задачи. Вот теперь можно обратиться к свойству Result (тип object) и получить тот самый результат, который обработчик события DoWork поместил в одноименное свойство класса DoWorkEventArgs. Если же обратиться к этому свойству, когда Error не равен null или когда Cancelled равен true, будет выдано исключение System.InvalidOperationException. Таким образом при помощи этих трех свойств всегда можно точно определить, как завершилась фоновая задача, и воспользоваться результатами ее работы. Формально говоря, у RunWorkerCompletedEventArgs есть и четвертое свойство – UserState (тип object). Но оно унаследовано от класса AsyncCompletedEventArgs (наследником этого класса является RunWorkerCompletedEventArgs) и (по крайней мере на текущий момент) никак не задействовано.

Рассмотрение внутреннего устройства нового класса BackgroundWorker практически завершено. Остался один большой вопрос, который я обещал рассмотреть отдельно – а как же происходит принудительное прерывание фоновой задачи основным потоком? Вспомните, что описывая второй параметр обработчика события DoWork, я уже говорил, что присвоение свойству Cancel этого параметра значения true не прерывает работу фонового потока. Это всего лишь предупреждение обработчику RunWorkerCompleted, что выполнение не было доведено до конца, и что последний не должен пытаться обращаться к свойству Result. Совсем формально говоря: задание свойству DoWorkEventArgs.Cancel значения true означает всего лишь присваивание того же значения свойству RunWorkerCompletedEventArgs.Cancelled. Реальный же механизм прерывания куда как изощреннее. Во-первых, чтобы такое “силовое” завершение фонового потока стало в принципе возможным, нужно задать свойству WorkerSupportsCancellation (тип bool; по умолчанию false) объекта BackgroundWorker значение true. Если этого не сделать отменить фоновую задачу не получится, она всегда будет работать до конца. Во-вторых, при условии, что предыдущее присвоение было выполнено, можно в качестве реакции на нажатие пользователем клавиши Cancel вызвать метод класса BackgroundWorker

public void CancelAsync();

Но и этот метод реально работу потока не прервет. Он всего лишь присвоит свойству CancellationPending (тип bool) того же класса значение true. А вот теперь самое важное, в-третьих. Внутри фоновой задачи (читай, внутри обработчика события DoWork) нужно периодически обращаться к указанному свойству (доступному только для чтения) на предмет выяснения – не вернет ли оно true? Как только такой факт обнаружен, разработчик сам прекращаем фоновую работу. В примере я просто выхожу из цикла, извлекающего “рисунки” из БД. Т.е. фоновому потоку дается возможность быстренько (но корректно) завершиться, не забыв, разумеется, предварительно выставить DoWorkEventArgs.Cancel в true. Нужно еще позаботиться об обработчике события RunWorkerCompleted, помните? Есть здесь и один тонкий момент. Дело в том, что вызов CancelAsync и, как следствие, задание значения свойства CancellationPending происходит в основном потоке, а анализ этого свойства – в рабочем. И здесь возникает ситуация, известной в многопоточности под кодовым названием “race condition”. Вполне возможно, что основная нить просто не получит процессорного времени, когда будет нажата кнопка Cancel, и не выставит флаг CancellationPending. А когда она получит такое время – фоновый процесс уже будет завершен сам по себе. Не сказать, чтобы вероятность описанных событий была чрезвычайно высока, но она определенно отлична от нуля. И знать о ней не помешает.

Ну и уж совсем последний штрих в рассмотрении нового класса – его свойство IsBusy (тип bool, только для чтения). Обратившись к нему, в любой момент можно узнать, жив ли фоновый поток, и выполняет ли он какое-либо задание. В примере я периодически это делаю, чтобы отобразить текущее состояние нити.

Как это выглядит

Пример к данному разделу запускается кнопкой "BackgroundWorker Demo" в главной форме. В появившейся форме находятся три кнопки для запуска фонового задания по вариантам Unsafe, Safe, BW, разобранным выше, а также control NumericUpDown для указания количества “рисунков”. Четвертая кнопка изначально находится в состоянии disabled и становится доступной только после запуска одного из вариантов фонового задания. Она служит для “силовой” отмены фонового задания. Помните, что Unsafe-вариант, будучи запущенным из-под VS 2005, выдаст исключение на первой же строчке кода, выполняющейся в рабочем потоке. Для просмотра этого варианта нужно запускать пример из Explorer-а. Назначение расположенного на форме флажка Throw Exception - продемонстрировать работу приложения в случае возникновения в рабочем потоке неперехваченного исключения (только для варианта BW). Если он помечен, то обработчик события DoWork после извлечения половины заказанного числа рисунков намеренно предпримет попытку деления на ноль. Разумеется, никакого перехвата исключения не происходит. Как уже говорилось, в этом случае фоновый поток (но не все приложение в целом!) терминируется и генерируется событие RunWorkerCompleted, в обработчике которого свойство Error из второго аргумента должно быть не null, а экземпляром класса DivideByZeroException. Практика полностью подтверждает теорию, в т.ч. и тот факт, что не перехваченное в фоновой нити исключение не приводит к краху всего приложения. Единственный момент - это, опять же, способ запуска приложения. Если запустить пример с помеченным переключателем из-под VS, то отладчик остановит выполнение приложения прямо на строчке деления на ноль, что, понятно, препятствует анализу свойства Error внутри события RunWorkerCompleted. Поэтому для изучения работы этого свойства запускайте приложение из Explorer-а – здесь оно проявит себя во всей красе. Помимо указанных control-ов на форме находится панель (изначально скрытая), которая появляется только в случае запуска варианта BW. Она динамически отображает состояние фоновой задачи на текущий момент: работает ли поток прямо сейчас, не была ли его работа прерван пользователем, не была ли она терминирована неперехваченным исключением. А если поток завершил свое исполнение без всяческих приключений, то та же панель отобразит результаты работы. В примере, как я уже упоминал, таким результатом всегда будет формальная константа 77.


Рисунок 19.

На что обратить внимание

Новый класс-компонент BackgroundWorker оставил самые благоприятные впечатления. Продуманная архитектура, уверенная работа – что еще нужно для плодотворной работы? Разве что знание того правила, которое неоднократно упоминалось в этом разделе. Но на одном моменте все же стоит остановиться. Еще больше усложню задачу с рисунками. Пусть они теперь извлекаются не из одной, а из трех различных БД, причем работа со всеми БД абсолютно аналогична, различаются только строки подсоединения к той или иной базе. Неплохо бы все эти три процесса распараллелить по трем нитям, не так ли? Что, если использовать три различных компонента BackgroundWorker? Легко! При этом можно назначить им один и тот же обработчик события DoWork, просто передавая ему на вход разные строки подключения. А теперь представьте: есть три объекта – backgroundWorker1, backgroundWorker2, backgroundWorker3. В обработчике события нужно, допустим, вызвать экземплярный метод ReportProgress(…). Но у какого именно объекта его вызывать? Очевидно, что строка backgroundWorker1.ReportProgress(…) изначально ошибочна, поскольку данный обработчик исполняется тремя различными потоками и прогресс может касаться вовсе даже не первого, а, например, третьего потока. Правильное решение – воспользоваться первым аргументом обработчика sender и привести его к типу BackgroundWorker, после чего вызвать требуемый метод этого, приведенного, объекта. Вот в этом случае каждая нить будет корректно сообщать о своем (и только своем) прогрессе.

WebBrowser

Говоря совсем уж точно, данный control нельзя назвать таким уж стопроцентно новым. В предыдущих версиях Visual Studio можно было поместить на панель инструментов тот же Microsoft Web Browser. Но это был не полноценный control, а всего лишь ActiveX-компонент. Тем не менее, после такой настройки панели инструментов можно было перетащить указанный компонент на форму и работать с ним. Так вот и появившийся в версии 2.0 control WebBrowser является всего лишь управляемой оберткой вокруг все того же ActiveX-компонента. Тем не менее, он делает работу с компонентом много удобнее и комфортнее, позволяя разработчику “общаться” с ним как с полноценным control-ом. Для чего же WinForms-разработчик может захотеть обратиться к новому-старому control-у? Ну, абсолютно очевидный повод – дать пользователю возможность просмотра web-страничек прямо из разрабатываемого приложения. Менее очевидная причина: если помощь к вашему приложению организована как набор HTML-страниц с перекрестными ссылками, то новый control будет как нельзя кстати для отображения такой помощи. Еще менее очевидная причина: у вас есть DHTML-элементы пользовательского интерфейса, которые вы хотели бы разместить на одной из форм Windows-приложения. А проще говоря – вы хотите скомбинировать Web- и Windows-control-ы в рамках одной формы, и скрыть тот факт, что часть из них выводятся WebBrowser-ом. Новый control вполне позволит вам осуществить такую задумку – “бесшовное” сращивание, казалось бы, несовместимых элементов. Полагаю, что даже изложенных трех причин вполне достаточно, чтобы обратить на новый control самое пристальное внимание и уделить достаточное количество времени на его изучение.

Как это обычно и бывает, работа с новым control-ом начинается с перетаскивания его на форму. Сразу обращает на себя внимание тот факт, что по умолчанию WebBrowser сразу же растягивается на всю родительскую форму (при условии, что на момент перетаскивания форма не содержит каких-либо прочих control-ов), т.е. явно пытается предстать в виде этакого “мини-IE”. И действительно, простейший браузер с помощью этого control-а изготавливается буквально за полчаса. Целый ряд методов этого control-а соответствует привычным кнопкам IE один к одному. Основным методом, безусловно, будет:

public void Navigate(string url);

Только имейте в виду, что данный метод имеет целых восемь перегруженных вариантов, а показан простейший из всех. Как вы, видимо, догадались, данный метод заставит WebBrowser направиться по указанному адресу и отобразить соответствующую страницу. Аналогичного эффекта можно добиться, прибегнув к свойству Url (тип string). Но метод предлагает гораздо более гибкие варианты перехода. В частности, именно с помощью метода можно указать, будет ли новая страница открыта прямо в WebBrowser, или будет открыт “полновесный” IE, который и отобразит требуемую страницу. Следующие методы

public bool GoBack();
public bool GoForward();
public void GoHome();
public void GoSearch();
public void Stop();
public void Refresh();

настолько самоочевидны и однозначно связываются с кнопками Назад/Вперед/Домашняя страница/Страница поиска/Стоп/Обновить “большого” браузера, что особо описывать их смысла нет. Отмечу только, что наш control, как и его старший брат, ведет собственную историю посещенных страниц, и первые два метода как раз и перемещаются по ней. В качестве, возможно, несколько избыточной, но не бесполезной информации, отмечу, что домашняя страница настраивается через “Панель управления”->”Свойства обозревателя”->”Общие”, а для изменения используемой по умолчанию страницы поиска измените в реестре (HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\Main) значение ключа Search Page. Кстати, если вы уж все равно зашли в реестр, можете изменить и значение ключа Start Page по тому же адресу, чтобы панель управления лишний раз не дергать. К этой же группе методов примыкают:

public void ShowPageSetupDialog();
public void ShowPrintDialog();
public void ShowPrintPreviewDialog();
public void Print();

тоже копирующие действия IE по распечатке страницы отображаемой в настоящий момент. Указанные методы позволяют вызвать диалоги установки страницы, принтера, и предварительного просмотра печати, соответственно. Последний метод просто печатает текущую страницу, используя текущие настройки страницы и принтера. Метод

public void ShowPropertiesDialog();

отобразит тот же диалог “Свойства”, вызываемый из контекстного меню. Наконец, метод

public void ShowSaveAsDialog();

позволит сохранить текущую страницу через тоже всем известный диалог сохранения “Save As...”. Как вы могли убедиться, методы практически копируют эффекты нажатия соответствующих кнопок/выбора пунктов меню IE. Назначение же ряда свойств и событий не столь очевидно. Рассмотрю их немного подробнее. Одно свойство – Url – уже упоминалось. Оно позволяет получить адрес загруженной страницы (при использовании get) или заставить control перейти к отображению новой (при использовании set). Свойства из следующего “пакета” могут быть, безусловно, использованы индивидуально, но чаще они будут применяться именно “пакетом”:

public bool AllowWebBrowserDrop {get; set;}
public bool WebBrowserShortcutsEnabled {get; set;}
public bool IsWebBrowserContextMenuEnabled {get; set;}
public bool ScriptErrorsSuppressed {get; set;}

Дело здесь вот в чем. По умолчанию control пытается выглядеть “взрослым” браузером. Поэтому при перетаскивании на него какого-либо документа он осуществляет навигацию к брошенному документу (свойство AllowWebBrowserDrop), реагирует на шорткаты IE (вроде Backspace для возврата на предыдущую страницу, свойство WebBrowserShortcutsEnabled), показывает IE-подобное контекстное меню при щелчке правой кнопкой мыши по странице (свойство IsWebBrowserContextMenuEnabled) и, наконец, выдает предупреждения при обнаружении ошибки скрипта HTML-страницы (свойство ScriptErrorsSuppressed). Соответственно, для такого поведения первые три свойства по умолчанию выставлены в true, а последнее – в false. В общем случае это хорошо и правильно. Но вспомните один из сценариев использования control-а WebBrowser: совместное использование Web- и Windows-control-ов в рамках одной формы. Здесь, конечно, лучше скрыть от пользователя тот факт, что часть control-ов отображаются не самой формой, а браузером. В частности, меню с пунктами “Вперед/Назад/Выделить все…” и т.п. в данном случае будет, мягко говоря, не к месту. Вот как раз для бесшовного сращивания этих двух типов control-ов на одной форме свойствам из указанного пакета рекомендуется присвоить противоположные значения. Теперь немного другой сценарий: при открытии формы с WebBrowser-ом требуется показать пользователю одну и только одну обычную страничку, но воспрепятствовать уходу с нее, в т.ч. и с помощью ссылок, на ней содержащихся. Такое поведение позволит организовать свойство AllowNavigation (тип bool). Изначально оно равно true, и пользователь может свободно перемещаться по ссылкам. Но если поменять его значение на противоположное, то control разрешит загрузку только одной страницы, начальной (Не путайте с домашней!. Начальная страница – любая страница, загружаемая в “чистый” WebBrowser). После этого переместиться с нее будет невозможно. Причем это не удастся сделать как пользователю (путем щелчка по ссылке), так и вам самим (из кода, с использованием метода Navigate)! Имейте этот момент в виду. Следующая пара свойств, CanGoBack и CanGoForward (оба типа bool и доступны только для чтения), сообщает, есть ли в истории посещений предыдущая/последующая страница для возможной навигации. Удобны для включения/выключения доступа к кнопкам Назад/Вперед на инструментальной панели, как и сделано в IE. Сама подобная навигация, напомню, реализуется парой методов GoBack()/GoForward(). Свойство EncryptionLevel также доступно только для чтения, и возвращает одно из значений перечисления WebBrowserEncryptionLevel. Его назначение – сообщить, использует ли текущий документ какой-либо метод шифрования, и если да, то какой именно. Для большинства “нормальных” страниц значением этого свойства будет WebBrowserEncryptionLevel.Insecure, т.е. нешифрованный документ. В прочих случаях будет возвращено одно из оставшихся шести значений для отображения уровня шифрования. В частности, набрав в приведенном ниже примере адрес https://login.passport.com и нажав на иконку закрытого замочка в нижнем левом углу формы (конечно, только после навигации по указанному адресу) можно убедиться, что страница логина службы Microsoft Passport Network использует 128-битное шифрование, что следует из возвращаемого рассматриваемым свойством значения WebBrowserEncryptionLevel.Bit128 (адрес надо набирать именно с префиксом https, набор только login.passport.com приведет на нешифрованный вариант страницы). Данное свойство работает в тесной кооперации с событием EncryptionLevelChanged, которое, собственно, и генерируется при переходе на шифрованную страницу или уходе с нее. Другая пара доступных только для чтения свойств IsBusy (тип bool) и ReadyState (тип перечисления WebBrowserReadyState) позволяют в любой момент ответить на вопрос: “Занят ли WebBrowser чем-нибудь полезным?”. Первое свойство просто сообщает, загружается ли в настоящее время какой-либо документ в control, а второе дает более подробную расшифровку состояния загружаемого документа, если таковая расшифровка требуется логике приложения.

Браузер может быть введен в состояние Offline, в котором он не пытается загрузить странички из сети, а берет их исключительно из локального кэша. Свойство IsOffline (тип bool) как раз и сообщает, не находится ли наш control в таком состоянии. Логичный вопрос – а как ввести его в состояние Offline (программно)? Автор данного опуса и сам задался им, и, к своему удивлению, ответа не нашел. По логике вещей рассматриваемое свойство должно быть “двунаправленным”, доступным на чтение и на запись. Это выглядит еще более обоснованным, если учесть, что интерфейс IWebBrowser2 (а именно через него осуществляется доступ к неуправляемому компоненту, которым, в сущности, и является WebBrowser) имеет соответственное свойство Offline того же типа bool, но работающее именно и на чтение и на запись. Почему свойство IsOffline управляемого control-а сделали доступным только для чтения – сплошная загадка, но… подождем релиза.

Другое свойство, ScrollBarsEnabled (тип bool), отвечает за отображение полос прокрутки внутри control-а. Изначально оно равно true, и означает, что как только размеры документа выйдут за границы control-а, появится соответствующая полоса прокрутки. Если же приравнять его false, то полосы показываться не будут. Очередное свойство StatusText (тип string, только для чтения) содержит тот текст, который в “большом” браузере появляется в строке статуса. Это может быть, например, адрес загружаемого в данный момент документа, или URL ссылки над которой “завис” курсор мыши. Это второй пример тесной кооперации свойство-событие. В этот раз событие StatusTextChanged будет сгенерировано при изменении рассматриваемого свойства. Самое время обновить строку статуса браузера новой информацией! Наконец, одно из второстепенных свойств – Version (одноименного типа) сообщит версию установленного в системе IE. Как уже неоднократно упоминалось, WebBrowser – это всего лишь управляемая обертка вокруг неуправляемого компонента. А последний устанавливается в систему вместе с IE. Соответственно, если рассчитывать на некоторые возможности, доступные только в версии IE 6.0 и выше, будет совсем нелишне поинтересоваться – а имеет ли клиентский компьютер требуемое? И, как вариант, предоставить альтернативную функциональность. А проще и правильнее предложить (но настойчиво предложить) обновить софт. :) Излишне говорить, что данное свойство доступно только для чтения.

Вот и все основные свойства нового control-а, за исключением группы, отвечающей за взаимодействие с отображаемым документом как с HTML-кодом. Может ли WebBrowser отобразить HTML-код, не из локального файла и не из сети, а, допустим, сформированный “на лету” в процессе работы приложения? Оказывается, может, да еще как! Свойство DocumentText (тип string) во-первых может вернуть текущую отображаемую страницу в виде HTML-кода (т.е. вернуть тот самый текст, который вы увидели бы в блокноте, открытом через пункт меню “Просмотр HTML-кода”), а во-вторых, произвести обратную операцию: отобразить переданный ему на вход набор HTML-тегов/атрибутов в виде форматированной страницы. Причем этот текст может содержать скриптовые вставки, элементы HTML типа <button id=…. >…</button> или <input type=… > и т.п. В примере я формирую страницу, где пара строк выводится Java-скриптом, а по нажатию на HTML-кнопку число "пи" умножается на 3, и ответ опять выводится на HTML-страницу. Причем само умножение выполняется не внутри HTML-кода. Вместо этого по нажатию HTML-кнопки вызывается внешний (для страницы) метод, написанный на чистом C#! Вот он-то и производит умножение, а страничка уже получает от него готовый результат. Но здесь есть одна хитрость. Моя динамически формируемая HTML-страница знает, как называется внешний метод (в данном случае его имя TriDouble), но не знает, где он расположен. Однако ей можно дать “подсказку” через свойство ObjectForScripting (тип object). В примере требуемый внешний метод TriDouble является членом того же класса, что и сам control WebBrowser (класс является формой). Поэтому я присваиваю этому свойству просто this, и HTML-код знает, что внешний метод следует искать в пределах того самого класса-формы. Но и после этого хитрости не заканчиваются. :) Дело в том, что просто присвоение

// _wb – объект типа WebBrowser, this – объект унаследованный от типа 
// Form, т.е. текущая форма 
_wb.ObjectForScripting = this;

вызывает System.ArgumentException с жалобой, что класс “this” не является видимым для COM. Поэтому придется навесить на класс-форму атрибут:

[ComVisible(true)]

И вот только после этого все хитрости преодолены и налажена уверенная взаимосвязь между HTML и C#-кодом. Ну и коли наш WebBrowser способен отображать динамически формируемые как plain-text странички, было б поистине странно, если бы он не смог сделать того же самого, когда в качестве источника HTML-кода выступают стационарные сущности типа файлов, записей баз данных и т.п. объединяемых под одной “вывеской” – поток данных. Разумеется, control способен и на это. Через свойство DocumentStream (тип Stream) можно писать код текущей загруженной страницы в поток, а также, напротив, загружать новую страницу из него. В примере с помощью этого свойства открывается и визуализируется файл, сформированный в MS Word и сохраненный как Web-страница. Но, как понимаете, варианты потока (Stream) далеко не ограничиваются только лишь локальным файлом. Наконец, еще одно свойство – Document (тип HtmlDocument) позволяет получить управляемый объект, представляющий из себя загруженную HTML-страницу. Это свойство возвращает объект класса HtmlDocument, появившегося только в .NET Framework 2. Суть последнего – дать высокоуровневый доступ к HTML-документу, в настоящий момент загруженному в WebBrowser. В примере с помощью этого свойства и этого класса я в одну строку кода извлекаю значение тега <TITLE> текущей страницы. Откровенно говоря, именно заголовок страницы извлекается еще проще через свойство самого WebBrowserDocumentTitle (тип string; только для чтения). Но возможность чтения заголовка страницы – это лишь мизерный процент тех возможностей, что предоставляет класс HtmlDocument по отношению к HTML-документу. Вы можете создавать новые теги-элементы и их подэлементы, извлекать теги-элементы по их ID, извлекать все рисунки с текущей страницы, узнать, какие ссылки на ней имеются и т.д. Впрочем, подробный разбор класса HtmlDocument явно выходит за рамки данной статьи. Просто резюмирую, что данный класс предоставляет полноценный доступ к HTML Document Object Model. Ну и завершу обзор свойств еще одним второстепенным свойством, DocumentType (тип string). Оно возвращает тип текущего загруженного документа. В данном случае под типом понимается MIME-тип документа. Примерами значений, возвращаемых данным свойством, являются строки “HTML Document” и “XML Document”.

WebBrowser предлагает также ряд интересных событий. Важнейшими из них являются три события навигации: Navigating, Navigated, DocumentCompleted. Они генерируются на разных стадиях загрузки страницы. Непосредственно перед тем, как control предпримет попытку перехода по новому адресу, генерируется событие Navigating. Через второй аргумент, переданный обработчику этого события, можно узнать целевой URL, имя того фрейма на странице, внутрь которого будет загружен новый документ; и, самое главное, через свойство Cancel этого аргумента можно отменить навигацию вообще, если, допустим, пользователь не ввел свое имя или ввел ошибочное имя. Если навигация была разрешена, то браузер переходит по требуемому адресу и приступает к загрузке документа. Именно в этот момент генерируется событие Navigated. Ничего интересного, кроме все того же целевого URL, обработчик данного события не получит. И, наконец, по окончании загрузки документа генерируется событие DocumentCompleted, обработчик которого, опять же, получает лишь целевой URL. Все три события в комплексе позволяют очень точно отслеживать фазы загрузки страницы и соответствующим образом обновлять пользовательский интерфейс. Если нужна еще более точная “нарезка” событий между Navigated и DocumentCompleted, на помощь приходит событие ProgressChanged. Оно периодически генерируется именно в процессе загрузки страницы, и предназначено, прежде всего, для организации индикатора прогресса загрузки документа, как это сделано в IE. Обработчик получает возможность обратиться к двум интересным свойствам: CurrentProgress и MaximumProgress. Оба имеют тип long, но первое говорит, какое количество байт уже загружено из сети, а второе показывает общее количество байт. Соответственно, для обновления индикатора прогресса в обработчике данного события идеально подходит строка кода:

this._tsProgressBar.Value = (int)
  (e.CurrentProgress * 100 / e.MaximumProgress);

Пара событий CanGoBackChanged/CanGoForwardChanged генерируется при изменении двух рассмотренных ранее свойств CanGoBack/CanGoForward. Событие DocumentTitleChanged поднимается при изменении свойства DocumentTitle, а проще – при изменении заголовка загруженной страницы. Первейшее назначение обработчика – обновить соответствующим образом заголовок формы, содержащей WebBrowser. Если пользователь щелкает по ссылке в загруженном документе, и это вызывает открытие нового окна браузера с загрузкой документа уже в этот, полноценный IE, то до того, как произойдет открытие нового окна, будет сгенерировано событие NewWindow. Обработчик этого события через свойство Cancel своего второго аргумента может отменить появление нового окна и, как следствие, загрузку документа в него.

Как это выглядит

Пример к данному разделу запускается кнопкой " WebBrowser Control" в главной форме. В появившейся форме находятся 2 инструментальных панели, строка статуса и непосредственно тестируемый control. Горизонтальная инструментальная панель воспроизводит, насколько это возможно, аналогичную панель из IE. Вертикальная позволит проверить в работе ряд сервисных функций, разбиравшихся выше. Строка статуса показывает примерно ту же информацию, что и аналогичный элемент IE, в т.ч. и индикатор прогресса загрузки страницы. Помимо этого, на ней же размещена иконка обозначающая степень шифрования текущей страницы (либо указывающая на отсутствие таковой – в этом случае замочек будет открыт, а сама иконка-кнопка будет неактивной). Навигация осуществляется вполне привычным образом: в адресную строку вписывается требуемый URL и нажимается Enter (можно щелкнуть по кнопке “GO!”). Обратите внимание, что можно ввести и адрес локального HTML/XML-файла, только не забудьте предварить его префиксом “file://”. Небольшого пояснения требует, пожалуй, лишь опция “Track Events”. Дело в том, что три основных события – Navigating, Navigated, DocumentCompleted генерируются в процессе работы постоянно и содержат довольно объемную дополнительную информацию. При этом порядок вызова обработчиков этих событий, а также аргументы, ими получаемые, представляют несомненный интерес для человека, вплотную изучающего WebBrowser. Поэтому было принято решение соорудить этакий “самопальный” трекер событий. По нажатию на соответствующую кнопку вертикальной инструментальной панели открывается дополнительное окно. После этого можно вернуться в форму и продолжать обычные эксперименты с WebBrowser. Указанные события (и параметры, с ними связанные) будут отображаться в этом окне. Ниже показаны оба окна – основное сверху и окно лога внизу.


Рисунок 20.

На что обратить внимание

WebBrowser особых хлопот не доставил. Четкая, уверенная работа в сочетании с широким спектром возможностей. Правда, неуправляемые корни порой дают о себе знать. Так, в примере сначала создается главная форма с набором кнопок, а уже по нажатию на одну из них открывается новая форма с WebBrowser. Так вот, после закрытия этой второй формы и после закрытия главной формы приложение не заканчивает работу нормально, а выдает System.AccessViolationException. Причем корень зла именно в новом control-е, т.к. если убрать его со второй формы и произвести те же самые действия, все будет отлично, приложение завершается без каких-либо вопросов. Несмотря на то, что это исключение возникает только в варианте запуска из-под Visual Studio (при запуске из Explorer никаких проблем не возникает), факт настораживающий. Любое исключение вызывает ненужные вопросы и волнения. Пока нашелся обходной маневр: после закрытия формы, содержащей новый control, принудительно ее (форму) удалять. В этом случае даже под студией приложение закрывается нормально. Но хотелось бы, чтобы с подобными формами можно было работать как с любыми другими, по принципу “закрыл и забыл”.

ClickOnce – новая технология развертывания приложений

“На сладкое” остается совсем уж не control и не компонент, а технология, имеющая прямое отношение к WinForms. Дело в том, что разработка, тестирование и отладка приложения – это совсем не весь жизненный цикл программного обеспечения. Не менее ответственной и сложной задачей является естественное продолжение этих усилий – развертывание (deployment) и регулярное обновление (updating) поставляемого ПО. Общепринятой практикой развертывания как раз таки .NET-WinForms приложений является сегодня создание установочных пакетов в рамках старой (не в смысле устаревшей, а в смысле существующей уже не первый месяц) технологии – Windows Installer (MSI). И все бы ничего, но при таком подходе к распространению приложения возникают три проблемы:

Понятное дело, эти проблемы были подмечены не вчера. И в существующей версии .Net Framework 1.0/1.1 уже была сделана первая робкая попытка справиться с ними. Я говорю о так называемом no-touch deployment, доступном уже в этих версиях. Собственно, если вы осведомлены об этом подходе к вопросу, считайте, что базовые знания по новой технологии у вас имеются, поскольку ClickOnce является прямым потомком и продолжателем дела no-touch развертывания. Правда, ребенок пошел куда дальше своего родителя, и даже грозится отобрать пальму первенства у старого MSI. Строго говоря, эти две технологии – MSI и ClickOnce – безусловно, являются конкурентами, поскольку отдавая в данной конкретной ситуации предпочтение одной, вы автоматически отказываетесь от другой. А вот для правильной оценки ситуации и выбора подходящей технологии требуется знание сильных и слабых сторон обоих вариантов. Пока еще есть (и будут) ситуации, когда MSI является безальтернативным вариантом. Но учтите, что в следующей версии OS (известной большинству под кодовым именем Longhorn) ClickOnce будет еще значительно усилена и расширена, становясь, по видимому, стандартом “де-факто” в инсталляции приложений. Разберем, как в текущей реализации ClickOnce решаются три проблемы, перечисленные в начале раздела:

Уже неплохо. Но основной идеей рождения ClickOnce приложений было даже не решение этих хотя и важных, но все же локальных проблем. В целом идея куда как глобальнее: предоставить конечному пользователю богатый интерфейс обычного приложения, но в тоже время обеспечить распространение такого приложения посредством Web. Или, проще говоря, создать у пользователя иллюзию, что он запускает Web-приложение (одним щелчком по ссылке в браузере) в то время, как запускается полноценное Windows-приложение. Коротко и официально такое “двуличное” приложение зовется умным клиентом (smart client). Подобное приложение обычно обладает еще целым рядом признаков, вроде использования централизованной базы данных, но здесь я сосредоточусь на двух важнейших: распространении и обновлении.

Как же данная идея технически реализована в новой технологии и насколько плотно она интегрирована в VS2005 и в сам Framework? В начале введу понятие “публикация”. Публикация – это набор файлов (*.exe, *.dll, файлы ресурсов и т.д.), необходимых для нормальной работы приложения, и ряд файлов, добавляемых самой инфраструктурой ClickOnce (о них будет сказано ниже). В терминах старого MSI публикация является аналогом выходного *.msi-файла, только здесь файлы не пакуются в один архив. Далее, производится размещение публикации. Размещение – это указание VS места, где должны быть сгенерированы файлы публикации. И, наконец, есть адрес инсталляции. Это адрес странички в интернете, на которую конечный пользователь должен проследовать, и на которой имеется ссылка, инициирующая установку ClickOnce-приложения. Разумеется, размещение и адрес инсталляции могут и совпадать (тогда второе поле в опциях проекта можно оставить пустым). Но это не всегда возможно. Вот пример из практики: при создании этой статьи я использовал известный хостинг narod.ru как место расположения тестовых файлов для их последующей установки на свой компьютер с помощью новой технологии. Если указать размещение как xxx.narod.ru (xxx – учетная запись на “ Народе”) и не заполнять адрес инсталляции, то VS будет пытаться разместить файлы публикации прямо на моей домашней странице. Разумеется у нее ничего не получится – для административного доступа к страничке нужны логин/пароль. Поэтому я указываю размещение как локальную директорию на диске C: а вот уже адрес инсталляции как xxx.narod.ru. Теперь студия генерирует публикацию на локальном диске и не пытается соединиться с интернетом. После окончания этого процесса я могу (например, по FTP) скопировать уже подготовленные файлы по адресу инсталляции и раздать этот адрес своим клиентам.

СОВЕТ

В процессе обсуждения стати на форуме участник vhonest совершенно справедливо заметил, что даже если целевой сервер требует авторизации (как в примере с narod.ru), то и в этом случае возможно автоматическое Размещение без нашего вмешательства. Для этого в поле publishing location (о нем речь пойдет в следующем разделе) необходимо ввести данные авторизации по шаблону:

<протокол>://<логин>:<пароль>@<целевой_сервер>/<директория_целевого_сервера>

Конкретный пример приведенный самим участником:

ftp://AccountName.nm.ru:password@ftp.nm.ru/ProjectName/

Собственно, данная процедура переноса сгенерированных файлов с локального диска в интернет в любом случае неизбежна. Даже если указать размещение как адрес web-страницы, все равно сначала будет создана локальная копия публикации, а потом уже VS перенесет ее по указанному адресу. Впрочем, как это и полагается в статьях, имеющих касательство к сетевым технологиям, для дальнейших экспериментов будем исходить из того, что на вашем локальном компьютере установлен Internet Information Services (IIS), именно он примет готовую публикацию, и именно с него она будет устанавливаться. Вопросы размещения публикаций на удаленных серверах, предоставляющих бесплатный/платный хостинг, выходят за рамки этого обзора. С целью сжатости изложения сосредоточусь на публикациях именно в их Internet-варианте, однако публикации могут быть размещены и в локальной сети (network file share), и даже (что значительно менее интересно, но возможно) на обычном CD/DVD. Введу еще два понятия из мира ClickOnce: устанавливаемое приложение и запускаемое приложение. Разница в следующем: когда конечный пользователь открывает в браузере страницу с публикацией, то, если последняя предлагает устанавливаемое приложение, он видит кнопку Install. Во втором случае он видит кнопку Run. Соответственно, в первом случае после щелчка задается вопрос о разрешении установки, а во втором – просто происходит запуск приложения, как если б оно было Web-приложением. Если некоторые читатели в этом месте сильно удивились тому моменту, что полноценное WinForms-приложение вдруг чудесным образом сконвертировалось в Web-приложение, то спешу их успокоить: чудес, как и прежде, в этом мире не бывает. :) Разумеется, это не более чем иллюзия Web-приложения. Технически в обоих вариантах загружаются одни и те же файлы, одинаковым образом устанавливаются и даже занимают одни и те же места на локальном HDD пользователя. Но пара различных черт все же имеется: устанавливаемое приложение добавляет свой ярлык в меню “Пуск”, делая возможным повторный запуск; запускаемое приложение ярлыка не добавляет и может быть повторно запущено только со страницы публикации. Далее, первый тип приложения добавляет себя в группу “Установка/удаление программ” панели управления. Отсюда конечный пользователь может либо удалить его полностью, либо сделать откат до предыдущей версии (об этом будет сказано ниже). Второй тип приложения ничего подобного не делает и не дает возможности конечному пользователю “цивилизованно” удалить свои файлы с жесткого диска – ведь оно продолжает “прикидываться” полноценным Web-приложением и делает вид, что никаких его следов на HDD не остается. Вообще, согласно документации, файлы, переносимые на пользовательский компьютер, попадают в специальный “кэш приложений”, подобный Web-кэшу в том плане, что система сама вычистит файлы из него. Но как, когда это происходит, и можно ли периодичностью такой чистки управлять, не говорится ничего. Могу лишь отметить, что за время многочисленных экспериментов файлы запускаемых приложений оставались совершенно нетронутыми. Что тому виной – бета самого Framework-а, слишком малое количество времени с момента запуска приложения и до момента его насильственного удаления самим автором или элементарное непонимание принципов работы этого загадочного “чистящего” механизма – судить не берусь. Вот краткое резюме по различиям этих двух типов приложений: помимо наличия ссылки в меню “Пуск” и ссылки в группе “Установка/удаление программ”, это два абсолютно идентичных варианта. Конечно, есть различия, так сказать, логического уровня. Например, устанавливаемое приложение может быть запущено, даже если у пользователя имеются локальные проблемы с доступом в интернет. А во втором случае это у него не получится – ссылки-то на запуск нет, и нет доступа к странице публикации. Или например, если выложить новую версию своего приложения, то в первом варианте (обычный сценарий) пользователь просто извещается об этом (и то только в том случае, если в момент запуска нашего приложения он подключен к сети) и имеет возможность (но не обязан!) дать разрешение на обновление существующей у него версии. И более того, даже если обновление произведено по прошествии некоторого времени, пользователь может произвести откат к предыдущей версии программы. Во втором же случае схема куда как примитивнее: при запуске (а это автоматически означает наличие рабочего соединения с интернетом) сравниваются версии в локальном “кэше приложений” и на странице публикации. Если да – запускается приложение из кэша, нет – сначала скачивается, а потом запускается новая версия. Все – пользователь гарантированно работает с последней (и только с последней) версией. Итак, чтобы выбрать, какое приложение предложить своим клиентам - устанавливаемое или запускаемое – надо учесть немало нюансов, но при этом с чисто технической точки зрения у того и другого больше похожих черт, нежели различий. Вот в связи с крайней похожестью обоих типов я буду говорить далее только об устанавливаемых приложениях, помня при этом, что часть их особенностей недоступна во втором типе приложений, а часть (к примеру, все что касается обновлений) просто не актуальна.

Как это выглядит

Пример к данному разделу оформлен в виде отдельного решения (solution), никак не связанного с предыдущими примерами. Точнее, это целый набор решений, позволяющий наблюдать различные аспекты установки, запуска и, самое важное и интересное, обновления устанавливаемого приложения. В качестве подопытного материала используется элементарная WinForms-программа, состоящая из одной формы. Откройте исходную версию данного решения по адресу <каталог_проектов>\ClickOnce\ver_1_0\ver_1_0.sln. Отмечу, что для перевода приложения в славную когорту ClickOnce-приложений в общем случае не требуется никакого дополнительного кодирования, или наследования от неких базовых классов, или реализации специфичных интерфейсов. Достаточно правильно выставить опции проекта, о чем и пойдет речь ниже. В данном случае C#-код проекта настолько элементарен, что вряд ли может представлять серьезный интерес. Вместо этого откройте подлежащие настройке свойства самого проекта (в Solution Explorer выберите Properties из контекстного меню проекта). В открывшемся окне свойств проекта целых три закладки слева посвящены именно работе с технологией ClickOnce: Signing, Security, Publish. Откроем последнюю из них.


Рисунок 21.

Publishing Location – это размещение, а Installation URL – это, соответственно, адрес инсталляции. И то и другое обсуждалось выше, и, надеюсь, нет необходимости еще раз повторять, почему в данном случае мы вполне можем оставить второе поле пустым. Далее с помощью переключателя Install Mode and Settings выберите, как и договорились, вариант инсталлируемого приложения. В самом низу идет исключительно важное поле Publish Version (версия публикации). Важность его заключается в том, что, во-первых, оно никак не связано с версией самого исполняемого файла (т.е. с версией сборки), и во-вторых, когда в технологии ClickOnce принимается любое решение по вопросу обновления текущей версии приложения, в расчет берется только эта версия – версия публикации. Именно так – версия самой сборки в вопросах обновления не играет решительно никакой роли, движок технологии ClickOnce учитывает только это поле. Наконец, последний флажок может заставить студию увеличивать последний номер (Revision) после каждой удачной публикации. Поскольку в моем примере каждая новая версия приложения – это отдельное решение с отдельными настройками проекта, я эту галочку снимаю. Теперь нажимите первую из четырех кнопок “Application Files…”. Здесь перечислены все файлы решения и их статус публикации. Решение состоит из трех файлов: основной сборки (SmartClient.exe) и двух маленьких картинок, фактически иконок в PNG-формате (ico_1.png, ico_2.png). Обратите внимание, что у первой картинки статус публикации изменен с используемого по умолчанию “Include” на “Data File”. Смысл и влияние данного изменения на процесс обновления (а связан он в первую очередь с ним) будет рассмотрен в свое время. Вторая кнопка “Prerequisites…” позволяет определить, что требуется клиентской машине для успешного запуска приложения, и, в случае отсутствия требуемого, где можно это взять. Подробный разбор всех возможных опций данного окна выходит за рамки данного обзора. Отмечу лишь, что можно довольно скрупулезно настраивать список необходимых компонентов и их местонахождение. Третья кнопка “Updates…” управляет процессом автообновления (а есть еще программный, кодируемый вариант, см. ниже). Самый главный в этом окне - первый флажок. Если его снять, то любой вариант обновления (хоть авто-, хоть программный) становится невозможным. Если он отмечен, то становится возможным (через переключатель) выбор двух принципиально различных подходов к процессу обновления: до старта приложения и после старта. При первом варианте запуск приложения через пункт меню “Пуск” означает совсем даже не запуск самого приложения, а запуск процедуры проверки обновления. Если оно найдено, следует его установка (с разрешения пользователя, разумеется) и запуск уже этой, новой версии. При втором варианте запуск приложения означает именно запуск самого приложения. Но в процессе работы запускается фоновый поток, который и выясняет, появилось новое обновление или нет. В любом случае текущая сессия работает со старой версией приложения. А вот при следующем запуске, если обновление найдено, следует запрос на установку. Понятно, что первый вариант несколько затягивает запуск самого приложения, но позволяет начать работу с новой версией приложения как можно быстрей и оперативнее. При втором варианте “по инерции” продолжается работа на старой версии приложения даже при наличии новой. Правда, второй вариант можно гораздо гибче настраивать: можно проверять наличие обновлений при каждом запуске, или каждые 5 дней, или каждые 12 часов и т.п. Еще одна крайне важная и интересная опция разбираемого окна – минимально требуемая версия приложения. К ней я обязательно вернусь в процессе экспериментов. Наконец последняя, четвертая кнопка “Options…”


Рисунок 22.

Здесь нужно заполнить поля “Publisher name” и “Product name”. Первое - это название новой группы в меню “Пуск”, а второе – названием ярлыка в этой группе и пункта в панели управления “Установка/удаление программ”. Можем заполнить “Support URL” – это будет еще один ярлык в группе с именем “Publisher name” в меню “Пуск”. Далее я изменил название самой Web-страницы с информацией о публикации с publish.htm на index.htm просто с целью облегчения навигации. Оставшиеся опции более-менее самоочевидны и подробных пояснений не требуют. Закройте окно “Publish Options” и страницу свойств проекта и запустите процесс публикации, который сводится к выбору пункта меню “Build->Publish SmartClient”. Появляется “Publish Wizard”, повторяющий, по сути, некоторые важные опции из закладки Publish со страницы свойств проекта. Можно пройти его весь, нажимая кнопку “Next>” и проверяя каждую опцию, но поскольку ничего нового этот визард не скажет, можно сразу нажать “Finish”. После короткой работы в строке статуса появляется сообщение, что публикация успешно размещена. В данном случае она успешно размещена на локальном IIS с именем localhost. Ознакомимся вкратце с физическим содержимым публикации. Поскольку виртуальный путь к ней – http://localhost/SmClient/, то при настройках IIS по умолчанию физическим путем будет c:\Inetpub\wwwroot\SmClient\. Файлы, копируемые туда, перечислены в таблице 10.

Путь\файлКраткое описание
setup.exeУстановщик необходимых компонентов из списка “Prerequisites…” опций проекта. Если при щелчке по кнопке “Install” (см. ниже) в браузере система замечает, что на целевом компьютере отсутствует один из требуемых компонентов, то запускается именно этот файл, который и проводит пользователя по тернистому пути установки всяческих недостающих компонентов. В противном случае ссылка кнопки “Install” ведет прямо на файл <база>\SmartClient.application и данный setup.exe попросту не используется.
index.htmСама отображаемая в браузере страница. По умолчанию имеет имя publish.htm, но имя может быть изменено.
<база>\SmartClient.applicationОдин из двух важнейших для всей технологии файлов – т.н. манифест развертывания. В случае, если все недостающее уже установлено, щелчок по кнопке “Install” запустит именно этот файл. Кстати, на машине должно быть зарегистрировано новое расширение .application, иначе система не распознает, чем же его открыть. Обычно регистрируется автоматически при установке Framework-а, так что скорее всего волноваться нечего. Структурно это обычный XML-файл, описывающий важнейшие характеристики публикации: версия публикации (крайне важно при обновлении), месторасположение второго “очень важного файла” – манифеста приложения, выбранная для публикации стратегия обновления и т.д.
SmartClient_1_0_0_0.applicationСовершеннейшая копия предыдущего файла. Но при публикации следующей версии приложения (а ведь она пойдет в эту же самую директорию!) предыдущий файл будет затерт манифестом развертывания от новой версии, а этот останется в неприкосновенности. Нужен для отката публикации к предыдущей версии.
<база>\SmartClient_1_0_0_0\ (каталог)Подкаталог базы. Содержит текущую версию приложения (для следующей версии будет создан свой подкаталог) а также его манифест.
<база>\SmartClient_1_0_0_0\ico_1.png.deploy <база>\SmartClient_1_0_0_0\ico_2.png.deploy <база>\SmartClient_1_0_0_0\SmartClient.exe.deployКопии файлов, составляющих приложение. В чем сакральный смысл навешивания дополнительного расширения .deploy, которое при развертывании просто отбрасывается, могут объяснить разве что авторы новой технологии. Есть гипотеза, что планировалось публикуемые файлы ужимать каким-то архиватором, передавать их в сжатом состоянии, а после этого движок ClickOnce-а разархивировал бы их и писал на локальный HDD обычные распакованные файлы. В таком случае новое расширение было бы более чем оправданно. Но… то ли еще бета пока, то ли руки не дошли, то ли идея не вдохновила… :) Одним словом, на текущий момент это просто битовые копии соответствующих файлов.
<база>\SmartClient_1_0_0_0\SmartClient.exe.manifest
Второй из важнейших файлов – манифест приложения. Структурно представляет из себя XML-файл, но в отличие от первого манифеста описывает не публикацию, а само приложение: сборки и файлы, из которых оно состоит, зависимости, а также крайне важную информации о доверии (trust information), в зависимости от которой целевой компьютер предоставит приложению те или иные полномочия.
Таблица 10.

Хорошо, какие же дивиденды мы имеем с новой публикации? Откройте браузер и перейдите по адресу инсталляции, который в данном случае совпадает с размещением:


Рисунок 23.

На открывшейся страничке с краткой информацией о приложении имеется кнопка “Install”. Нажатие на нее запускает установку приложения. Поскольку на моей машине все необходимые компоненты установлены, установлены, запускается не файл setup.exe, а непосредственно SmartClient.application. Действительно, сразу по нажатию система стартует новый процесс dfsvc.exe (Distributed Files SerViCe?), который и является тем самым движком технологии ClickOnce - любое ClickOnce-приложение никогда не запускается напрямую. Впрочем, к движку я еще вернусь, а пока отмечу, что запускаемый файл этого процесса размещается по пути C:\WINDOWS\Microsoft.NET\Framework\v2.0.50215\dfsvc.exe для той версии beta2, что установлена на моей машине. Этот же факт отвечает на вопрос “является ли ClickOnce частью Framework и необходимо ли второе для работы первого?” Ответ на обе части вопроса – категорическое да. Разумеется, только ClickOnce-движок знает как разбирать манифест развертывания и какие шаги предпринимать в зависимости от его содержимого. В данном случае он выдает запрос на локальную установку приложения:


Рисунок 24.

После предупреждения о том, что издатель неизвестен, разрешается нажать на еще одну кнопку “Install”. После этого начинается собственно установка, по окончании которой приложение автоматически запускается:


Рисунок 25.

Данное приложение сообщает свою версию (версия публикации, конечно - к версии самой сборки это никакого отношения не имеет), отображает две маленьких иконки и имеет кнопку для проверки (программным путем) наличия обновлений. Пока использовать кнопку смысла нет – ведь это первая и единственная версия приложения. Поэтому можно просто закрыть приложение. Однако это устанавливаемое приложение и, по идее, есть возможность запускать его из меню “Пуск”. OK, в группе “SelfPublisher” действительно есть ярлык “Demo of Smart Client talents”.


Рисунок 26.

Кстати говоря, ярлык - это не такой-то там банальный *.lnk-файл. На самом деле это файл еще одного нового типа - .appref-ms. И опять, как не сложно догадаться, это расширение должно быть зарегистрировано в системе, а работать с ним умеет только движок ClickOnce. И действительно, щелчок по ярлыку приводит к запуску движка, который первым делом производит подключение к серверу (это срабатывает запуск процедуры проверки обновления), после чего запускается та же самая форма – сборка SmartClient.exe.

Какие же изменения, помимо вполне очевидного создания в группе “Программы” новой подгруппы и нового ярлыка в ней, произвел процесс установки на локальном HDD? Прежде всего нужно отметить те изменения, которые могли бы произойти при использовании MSI, но не произошли в данном случае: в папку “Program Files” ничего добавлено не было, реестр системы также остался нетронутым, никаких новых иконок на рабочем столе не появилось. И не забудьте, что установить приложение можно, не обладая правами администратора на данной машине. А вот какие изменения вызвал новый способ инсталляции: в папке c:\Documents and Settings\<текущий_пользователь>\Local Settings\Apps\ была создана хитрая иерархическая структура с совершенно дикими именами папок вроде “smar..tion_033a11a49ab76479_0001.0000_d17bcb50e713c836” Полностью структуру папок я приводить не буду. Отмечу только, что непосредственно в каталоге Apps были созданы две папки: одна с именем Data, а другая – “9QR19VBW.H2M”. В глубинах первой (уровня еще на 4 ниже) нашел себе приют файл ico_1.png. В глубинах же второй разместились 2 оставшихся файла приложения – ico_2.png и сама сборка (плюс еще некоторые инфраструктурные файлы, которые здесь можно проигнорировать). Упрощая: появился каталог данных (…\Apps\Data\) и каталог приложения (..\Apps\9QR19VBW.H2M\). Основываясь на статусе публикации каждого файла (помните работу с окном “Application Files” на странице свойств проекта?) движок ClickOnce разнес их по соответствующим директориям. Файл со статусом “Data File” был отправлен в каталог данных, а со статусом “Include” – в каталог приложения. Но, конечно, только ради разделения файлов по каталогам не стоило и огород городить. Истинный смысл подобных телодвижений выяснится только при установке обновлений приложения. Пока же можно сделать следующий вывод: в силу своей архитектуры ClickOnce-приложение доступно только установившему его пользователю. Второй пользователь того же компьютера должен будет установить свою собственную копию в свой каталог, и эта копия будет полностью отделена и изолирована от первой. Вариант установки per-machine, доступный в случае MSI-инсталляции, в случае ClickOnce исключен.

Следующий логический шаг – попробовать функции обновления, о которых столько говорилось выше. Для этого закройте в VS2005 решение для приложения версии 1.0 и откройте <каталог_проектов>\ClickOnce\ver_1_1\ver_1_1.sln. Данное решение является совершеннейшей копией предыдущего за единственным исключением: на странице свойств проекта на закладке Publish поле Publish Version изменено на 1.1.0.0 (рисунок 26).


Рисунок 27.

Этого единственного изменения достаточно, что бы ClickOnce посчитал данную копию новой версией приложения. Запустите процесс публикации (“Build->Publish SmartClient”) и сразу нажмите “Finish”. После сообщения об успешном завершении можно убедиться, что виртуальный сервер содержит обе версии публикации – 1.0 и 1.1. Для установки новой версии уже не требуется запускать браузер. Кстати говоря, если бы версия 1.0 не была установлена ранее, устанавливалась бы уже версия 1.1. Кнопка “Install” по-прежнему ссылается на манифест развертывания SmartClient.application, но это уже манифест новой версии.

При щелчке по уже знакомому ярлыку из меню “Пуск” вместо запуска формы движок ClickOnce выведет такое сообщение:


Рисунок 28.

Что ж, согласимся сгрузить новую версию нажатием на “OK”. После секундной паузы запускается уже новая версия приложения, что подтверждается первой строчкой на форме:


А что же изменилось на локальном HDD? В каталоге приложения была создана еще одна папка для новой версии и туда сгружены ico_2.png и *.exe-сборка. Теперь этот каталог содержит обе версии приложения. А вот в каталоге данных новая папка была создан, но файл ico_1.png в нее был просто скопирован из версии 1.0. Это видно по времени создания обоих файлов, а также по времени создания второй папки. Так вот в чем смысл статуса публикации “Data File” и чем он отличается от “Include”! Файлы первого типа загружаются на локальный компьютер только если они изменились. Файлы второго типа безусловно скачиваются всякий раз при обновлении приложения. Таким образом, тип публикации “Include” хорошо подходит для небольших мобильных файлов и сборок приложения, с большой степенью вероятности меняющихся от версии к версии. Тип “Data File”, напротив, годится для “массивных” файлов данных и ресурсов, меняющихся только с выходом действительно уж капитальных обновлений. Короткое резюме: правильно подбирая тип публикаций для файлов вашего приложения, вы можете значительно повысить общее впечатление пользователя от скорости и удобства автоматического обновления. И кстати - каталог данных создается при установке всегда и безусловно, даже если ни один из файлов приложения не имеет статуса публикации “Data File”. Так что если вам нужна подходящая папка для динамически создаваемых файлов данных - каталог данных всегда тут и к вашим услугам. А вот как получить доступ к нему, я скажу чуть позже. При этом (заметьте!) обновление вызовет миграцию таких файлов из каталога данных предыдущей версии в каталог данных новой версии (так же, как в случае с файлом ico_1.png). Таким образом, все пользовательские данные сохраняются при обновлении версии приложения.

Но давайте посмотрим еще на один аспект этого процесса. Закройте решение версии 1.1 и откройте <каталог_проектов>\ClickOnce\ver_1_2\ver_1_2.sln. Здесь уже изменения посущественнее. Помимо вполне очевидного изменения Publish Version до 1.2.0.0, файл-иконка ico_1.png (имя то же, но содержимое другое; в 1.1 это был зеленый кружок с галочкой внутри, а в 1.2 - чашка кофе). Такое изменение позволит убедиться, действительно ли для файлов со статусом “Data File” автоматически распознается изменение их содержимого (согласно документации, ClickOnce должен рассчитывать хэш таких файлов и на этом основании делать вывод об их идентичности или различиях) и будут ли они сгружены на локальный компьютер в этом случае. При неизменном содержимом эти файлы просто копируются из одной версии в другую.

Запустите процесс публикации и по окончании опять выберите уже знакомый ярлык из меню “Пуск”. Появляется точно такое же окно, как при переходе от версии 1.0 к 1.1. Но сейчас мы поступим чуть хитрее и вместо “OK” выберем “Skip” (допустим, срочно надо работать и нет ни секунды на ожидание загрузки новой версии). Разумеется, после такого выбора запускается устаревшая (теперь уже) версия 1.1. Но что будет, если закрыть эту версию и запустить ее вновь? Появится ли вновь приглашение к обновлению? Ведь движок ClickOnce безусловно в курсе, что используется не последнюю версию! Оказывается, нет – приглашение больше не появляется. Логика понятна: пользователю дали знать, что появилось новое некритическое обновление (разницу с критическим я покажу ниже) и предложили его установить. Отказался – его дело. Может, человек обновлений как огня боится, что его постоянно вопросами донимать? Поэтому версию 1.2 пометили как “предложенную, но отклоненную”, и приглашений больше выдавать по этой версии не будут. Но вот с выходом следующей версии ClickOnce вновь напомнит о себе. Что же делать? А вот тут на помощь приходит обновление по запросу или программное обновление. Суть в том, что по какому-либо действию пользователя (в примере таким действием является щелчок по единственной кнопке на форме) программно выполняются те же действия, которые проводит движок ClickOnce при поиске наличия новой версии приложения. Итак, вернусь на короткое время к коду проекта, но прежде сделаю небольшое замечание. Хотя, казалось бы, программное обновление может при желании полностью заменить собой автоматическое и сделать его ненужным, не спешите снимать галочку “The application should check for updates” в окне “Application Updates”, вызываемого по кнопке “Updates…” на закладке Publish страницы свойств проекта. Эта галочка, конечно, отключит автообновление и ускорит загрузку приложения, но поступать так следует, только если вы действительно не планируете никакого обновления – ни автоматического, ни программного. Если же снять ее и затем попытаться из кода обратиться к методам ClickOnce-обновления, то будет выдано исключение System.InvalidOperationException с сообщением:

Application cannot be updated programmatically.

В данном случае обновление будет работать в кооперации с автоматическим обновлением, но ни в коем случае не подменять его. В разбираемом сценарии такая кооперация придется как нельзя более кстати. Ну а теперь – код. Чтобы начать использовать API движка ClickOnce, нужно включить в код директиву:

using System.Deployment.Application;

Именно в этом пространстве имен “проживает” класс ApplicationDeployment. Обратите внимание, что здесь имеет место быть забавный казус: во второй бете есть два класса с именем ApplicationDeployment: один в указанном пространстве имен, а второй – в пространстве имен System.Deployment. Однако класс из последнего пространства имен помечен забавным атрибутом:

[Obsolete("This is a dummy class. Switch to System.Deployment.Application.ApplicationDeployment instead.")]

Одним словом, директива, приведенная выше, верна, а директива “using System.Deployment;” была бы ошибочной. Все классы пространств имен, о которых здесь говорится, находятся в сборке system.deployment.dll. Не забывайте о соответствующей ссылке в проекте (в проектах эта ссылка уже имеется). Поскольку разбор программной формы обновления является лишь побочной темой разговора, я не стану детально разбирать свойства, методы и события данного класса. Но самое необходимое для достижения цели будет рассмотрено. Прежде всего, любые члены данного класса не должны применяться в программах, не установленных одним из возможных методов ClickOnce (Web, FTP, локальная сеть, CD/DVD). Если вы просто написали приложение в VS2005 и запустили его кнопкой F5, любое обращение к любому члену указанного класса вызовет исключение. Класс ApplicationDeployment предназначен только для ClickOnce-приложений. Единственный член класса, не подпадающий под это правило – статическое свойство IsNetworkDeployed (тип bool, только для чтения). К нему можно обратиться из любого приложения. Его предназначение, собственно, и заключается в том, чтобы сказать, является ли текущее приложение ClickOnce-приложением. В этом случае указанное свойство вернет true. Обратите внимание, что в своем примере я такой проверки не делаю, т.к. уверен, что приложение будет точно использоваться только в варианте ClickOnce. Но в общем случае такая проверка не помешает. Хорошо, теперь, когда точно известно, что это именно ClickOnce-приложение, и работа с разбираемым классом ему вполне показана, выясняется, что у данного класса нет публичного конструктора и создать объект этого типа самостоятельно нельзя. Зато второе статическое свойство CurrentDeployment (тип ApplicationDeployment, только для чтения) как раз вернет так нужный объект требуемого типа. Причем для каждого индивидуального развертывания будет создан свой объект. Свойство CurrentDeployment возвращает объект, ориентированный именно на текущую установку, т.е. ту, из которой данное приложение было запущено. После создания объекта становятся доступными экземплярные свойства/методы. Но прежде всего нужно отметить, что процесс обновления разбивается на две фазы: поверка наличия новой версии приложения (фаза 1) и, в случае наличия таковой, ее скачивание и установка (фаза 2). Разбираемый класс предлагает два пути выполнения обеих фаз: синхронный и асинхронный. Как несложно догадаться, первый путь приведет к невозможности работы пользователя с приложением до полного окончания процесса. Иногда это вполне оправдано – если, например, нужно, чтобы пользователь продолжил работу в новой и только в новой версии. Напротив, второй путь проводит всю работу в специально создаваемом для этой цели фоновом потоке, и пользователь может спокойно продолжать работу с текущей версией. Поскольку асинхронный путь представляется лучшим вариантом, сосредоточусь именно на нем. При таком подходе к вопросу общепринятый способ сообщить об окончании любой из фаз – сгенерировать событие. Два события, без которых определенно не обойтись при асинхронном обновлении: CheckForUpdateCompleted и UpdateCompleted. В конце первой фазы генерируется первое событие, в конце второй – второе. Поэтому первый шаг после создания объекта типа ApplicationDeployment – подписка на эти два события:

//_ad – объект типа ApplicationDeployment
this._ad.CheckForUpdateCompleted += _ad_CheckForUpdateCompleted; 
this._ad.UpdateCompleted += _ad_UpdateCompleted;

При инициировании пользователем процесса программного обновления код, со своей стороны, инициирует первую фазу:

// CheckForUpdate() запустил бы синхронный вариант проверки наличия 
// новой версии 
this._ad.CheckForUpdateAsync(); 

Теперь программа спокойно продолжает заниматься своими делами и ждет первого события CheckForUpdateCompleted. Замечу, что ApplicationDeployment настолько любезен, что вызывает обработчики обоих событий в “правильной”, UI-нити, так что можно тут же в обработчике работать с control-ами формы. Обработчик первого события в качестве второго параметра получает объект типа CheckForUpdateCompletedEventArgs, который, в свою очередь, предоставляет целый ряд интересных свойств. Мы воспользуемся только одним свойством, UpdateAvailable (тип bool). Если оно возвращает true, то новая версия обнаружена, и есть все основания прямо из этого обработчика инициировать фазу 2:

// Update() запустил бы синхронный вариант загрузки и инсталляции 
// новой версии
this._ad.UpdateAsync(); 

Продолжительность этой фазы даже при самом качественном соединении может измеряться минутами, а то и часами, в зависимости от объема файлов новой версии. Вот тут асинхронный путь приходится чрезвычайно кстати. Все-таки часами смотреть на “замерзшее” приложение – занятие не из числа увлекательных. После завершения второй фазы в обработчике второго события можно известить пользователя, что новая версия готова для использования. Вполне резонным представляется закрытие текущей версии и открытие новой, т.е. перезапуск приложения. Для этого шага идеально подходит новый статический метод Application.Restart(). Именно его я и вызываю в обработчике события UpdateCompleted. Чтобы закончить с классом ApplicationDeployment, скажу, что его экземплярное свойство CurrentVersion (тип Version, только для чтения) возвращает версию публикации, которая и отображается в первой строке формы. И еще об одном крайне важном и нужном экземплярном свойстве нельзя не упомянуть - DataDirectory (тип string). Оно возвращает физический путь к каталогу данных для данной версии данного приложения (да, этот каталог, равно как и каталог приложения, индивидуален не только для каждого приложения, но и для каждой версии одного и того же приложения). Именно с помощью этого свойства я получаю доступ к файлу “данных” ico_1.png, хотя на момент написания кода не имею ни малейшего понятия, где физически этот файл будет размещен.

Вот так реализуется программное обновление. На текущий момент на локальном компьютере установлена версия 1.1, сервер публикации содержит версию 1.2, но движок ClickOnce больше не приглашает сделать это обновление. Самое время блеснуть программному обновлению! Запустите (если она закрыта) версию 1.1 и нажмите на единственную кнопку “Check update...”. Результат будет выглядеть так, как показано на рисунке 28.


Рисунок 29.

Новая версия была обнаружена, загружена, установлена и запущена вместо старой. И все это в фоновом режиме и по нажатию одной кнопки. Сравнение верхней иконки версий 1.2 и 1.1 не оставляет никаких сомнений: движок ClickOnce обнаружил изменения файла данных и вместо миграции (как это было в момент обновления 1.0->1.1) произвел загрузку с сервера публикации. Но что, если иконка версии 1.1 нравится пользователю больше, чем чашка с кофе? Кто-то грозил возможностью откатов к предыдущим версиям? Откройте в панели управления “Установка/удаление программ”, найдите “Demo of Smart Client talents” и нажмите кнопку “Заменить/Удалить”:


Рисунок 30.

Действительно, предлагается либо откатиться на предыдущую версию, либо полностью удалить приложение. При выборе первого пункта после короткой паузы появится сообщение о том, что предыдущая версия доступна из меню “Пуск”. При попытке запуска откроется приложение версии 1.1 (включая и иконку). То, что автообновление полностью игнорирует размещенную на сервере публикации версию 1.2, вполне ожидаемо. А что скажет программное обновление? "No new update detected". Итак, версия 1.2 помечена ClickOnce как нежелательная для данного пользователя и более недоступна ни под каким соусом. Как вам уровень сервиса? ;) Оба вида обновлений будут снова доступны начиная с версии 1.2.0.1, но не раньше. А можно ли откатиться еще на шаг, к 1.0? Простое посещение панели управления убеждает в невозможности этого шага – активен только второй переключатель, “Remove the application…”. Документация также подтверждает это - устанавливаемое приложение хранится на компьютере в двух вариантах: текущем и предыдущем. Многоуровневые откаты исключены.

Решение для версии 1.3 находится в <каталог_проектов>\ClickOnce\ver_1_3\ver_1_3.sln. В этой версии я заменил обе иконки. Но самое важное заключается не в них, а в настройках проекта. На уже знакомой закладке Publish поле Publish Version изменено на 1.3.0.0 – это ожидаемо. Но откроем окно “Application Updates”, вызываемое по кнопке “Updates…”:


Рисунок 31.

Помните упоминание про некритические и критические обновления? Положим, мы, как разработчики, решаем – новые иконки, внесенные в версию 1.3, настолько хороши, что в дальнейшем пользователи обязаны лицезреть только их, а о возврате к иконкам версий 1.0-1.2 (включительно) и речи быть не может. Понятно, что в реальной жизни для принятия столь радикального решения нужны основания посерьезнее, чем красивость иконок :). Но для примера сойдет и эта причина. Так вот, после принятия такого решения, версии, относительно которой она принята, присваивается статус критического обновления. Движок ClickOnce извещается об этом путем выставления галочки (рисунок 30) и заполнения четырех полей для различных частей номера этой самой версии с критическим статусом. К чему это приводит на практике? После публикации версии 1.3 запускается не версия 1.1, и даже не ожидаемое окно с приглашением обновиться до версии 1.3. Запускается сама версия 1.3! Вот что произошло: ClickOnce заметил появление новой версии на сервере, а по наличию атрибута minimumRequiredVersion="1.3.0.0" в манифесте развертывания новой версии понял, что имеет дело с критическим обновлением. Поэтому он молча и безо всяких разговоров обновил приложение до требуемой версии. Таким образом, критическое обновление является безусловно устанавливаемым, в отличие от не критического которое, как вы видели, может быть пропущено. Откат к предыдущей версии после критического обновления невозможен. Переключатель “Restore the application…” в уже знакомом окне “Demo of Smart Client talents Maintenance” будет отключен, разрешено только полное удаление приложения.

На что обратить внимание

В приведенных примерах рассматривался сценарий развертывания и обновления приложения, носителем которого являлся Web-сервер. Не следует забывать однако, что носителем может быть и локальная сеть и CD/DVD, а приложение может быть и запускаемым. За рамками этого обзора остались такие немаловажные вопросы новой технологии, как:

Коротко резюмируя: новая технология дает уникальное сочетание богатства интерфейса Windows-приложения и легкости установки/поддержки Web-приложения. Можно прогнозировать, что какое-то время технологии ClickOnce и MSI будут сосуществовать. Но по мере приближения “эпохи Longhorn” первая будет занимать все более и более доминирующие позиции в силу развития общей идеи “каждое приложение – закрытый набор автономных файлов”. Да и от соблазна воспользоваться готовым Framework-ом для регулярного обновления приложений удержаться будет не просто. Что же касается реализации, то уже сейчас существует мощная база, не позволяющая сомневаться в том, что ClickOnce приходит “всерьез и надолго”. Да, пока не все гладко и идеально. Приложения, сконфигурированные для автоматического обновления после запуска (в противоположность разобранному нами примеру, сконфигурированным на автоматическое обновление до запуска), работают не так стабильно и уверенно, как можно было бы ожидать. Нет полной и четкой документации, а та, что есть местами, явно не соответствует действительности. Это дает повод лишний раз с гордостью заявить, что ни один технический писатель не в силах угнаться за беспокойной мыслью программиста. :) Бывает, что установленное и многократно запущенное с локального HDD приложение вдруг заявляет, что оно не инсталлировалось (!) и предлагает повторить процесс установки. Остается надеяться, что к выходу retail варианта все эти помарки будут устранены.

Заключение

В этой статье дан обзор новых control-ов, предоставляемых уже практически готовой к выходу в большой мир второй версией .NET Framework. Были упомянуты, на мой взгляд, все значительные новшества из раздела WinForms. Правда, первоначально я планировал включить в статью еще один раздел, посвященный control-у DataGridView – потомку и заместителю хорошо известного DataGrid. Но оценив, что такая глава увеличит объем статьи еще процентов на 40, я от этой идеи отказался. Тем более, что по большому счету этот “новый-старый” control вполне достоин быть героем отдельной публикации. За исключением упомянутого момента я постарался не упустить из поля зрения ничего важного и интересного, проведя немало экспериментов и исследовательской работы с бета-версией продукта. Сравнивая впечатления от работы двух “дуэтов” – FW1.1+VS2003 vs. FW2.0+VS2005 – можно уверенно констатировать факт значительного прогресса и увеличения продуктивности работы разработчика на новой платформе. Новые классы, новые особенности дизайнера, да и сам язык (я говорю, конечно же, о C#) стал лучше и мощнее. Любые замечания, поправки, возражения и просто предложения будут с благодарностью приняты и тщательно проанализированы. Спасибо, что были с нами на протяжении всей статьи. :)


Эта статья опубликована в журнале RSDN Magazine #4-2005. Информацию о журнале можно найти здесь
    Сообщений 60    Оценка 1938 [+1/-1]         Оценить