Недавно столкнулся с весьма интересной задачей, которая довольно-таки часто может встречаться при проектировании пользовательских интерфейсов. Вопрос, конечно, сравнительно тривиальный, однако полноценной и развёрнутой информации по нему я не нашёл, потому решил поделиться собственным опытом. Статья может оказаться полезной для Junior-разработчиков, а также людей, только начинающих изучать ООП и не имеющих серьёзного практического опыта в программировании.
Задача построения однотипных форм с шаблонной логикой
Суть состоит в том, что нам необходимо создать энное количество форм, эквивалентных друг другу в определённой степени. То есть у каждой из этих форм могут присутствовать одинаковые поля, методы, бизнес-логика, но при этом они не будут являться абсолютно равноценными. У каждой из них может быть свой набор методов, переменных, визуальных стилей и прочих компонентов, характерных именно для её представления. Применительно в моём случае это были формы для создания заявок на выполнение производственных работ, притом что у каждой работы имелся индивидуальный набор полей.
Как наиболее грамотно спроектировать подобную систему, чтобы она была максимально удобной в использовании и минимально избыточной в реализации? Очевидно, что нагромождение контролов на все случаи жизни, часть из которых можно будет скрыть или деактивировать — далеко не всегда является удачным вариантом. Во-первых, со временем количество параметров может разрастись до такой степени, что полный набор уже не будет укладываться в размеры окна. Во-вторых, выглядеть такая система будет крайне перегруженной и абсолютно некомфортной для пользователя. Исходя из этого можно сказать, что предпочтительнее будет использовать разграничение функционала с разделением компонентов.
При разработке нескольких похожих форм возникает следующая проблема. Так как большинство методов и полей в каждой из них совпадает, то при изолированной реализации логики для каждой отдельно взятой формы, как это может сделать любой неопытный программист, возникает избыточность параметров и много повторяющегося кода. Следовательно, при каких-либо изменениях в структуре объектов либо в логике программы необходимо отдельно править каждый метод, что выливается в монотонную копипасту и значительную потерю времени на лишние действия, в том числе и на возможные ошибки при вставке «не туда».
Логически грамотным решением в подобной ситуации будет применение полиморфизма. Во избежание случаев, подобных описанному в предыдущем абзаце, в языках, реализующих объектно-ориентированную парадигму программирования, специально были разработаны такие фичи, как абстрактные классы — классы, содержащие абстрактные методы и свойства, которые могут использовать любые унаследованные от него потомки. Именно их мы возьмём за основу в данном примере.
Практическая реализация на WPF
Свой проект я разрабатывал именно на WPF, так как требовалась высокая гибкость и весьма сложная структура форм. Однако принцип данного подхода общий для любых платформ и языков, потому его можно свободно применять в Web, мобильной разработке и много где ещё.
Для демонстрации возможности абстрактных классов на примере пользовательского интерфейса сформулируем исходную задачу следующим образом:
Необходимо рассчитать суммарную прибыль от проката кинофильма на основе имеющейся статистики. При этом фильм может быть двух разновидностей: полнометражный фильм или сериал. Для полнометражных фильмов прибыль рассчитывается на основе суммарных кассовых сборов от кинопроката. Для сериалов — по общей выручке от телеканалов. По результирующим данным вынести вердикт: оказался ли фильм прибыльным, если да — каков доход, если нет — каков убыток. Предусмотреть возможность изменения и сохранения расчётных параметров.
Для начала создадим класс Movie, описывающий кинофильм:
public class Movie
{
public Movie(string Name, byte Type, int Cost, int? Dues, int DuesTV, int DuesExtra, short? CinemaPart, short? DistrPart)
{
this.Name = Name;
this.Type = Type;
this.Cost = Cost;
this.Dues = Dues;
this.DuesTV = DuesTV;
this.DuesExtra = DuesExtra;
this.CinemaPart = CinemaPart;
this.DistrPart = DistrPart;
}
public string Name { get; set; }
public byte Type { get; set; }
public int Cost { get; set; }
public int? Dues { get; set; }
public int DuesTV { get; set; }
public int DuesExtra { get; set; }
public short? CinemaPart { get; set; }
public short? DistrPart { get; set; }
}
Обозначения параметров:
- Name — название картины
- Type — тип, 0 — фильм, 1 — сериал
- Cost — суммарный бюджет
- Dues — прибыль с кинопроката
- DuesTV — прибыль с телевидения
- DuesExtra — доп. прибыль (DVD, прокаты)
- CinemaPart — доля кинотеатров от прибыли
- DistrPart — доля дистрибьюторов
Опишем главную форму, содержащую раскрывающийся список с перечнем фильмов, и заполним его четырьмя элементами:
<Window x:Class="Earnings.Movies"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Earnings"
mc:Ignorable="d"
Title="Прибыль от кинофильмов" Height="150" Width="249" Style="{StaticResource WindowStyle}" ResizeMode="NoResize" WindowStartupLocation="CenterScreen">
<Grid>
<ComboBox x:Name="movieList" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="212" SelectionChanged="movieList_SelectionChanged"/>
<Label x:Name="_Type" Content="Категория:" HorizontalAlignment="Left" Margin="10,41,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<Label x:Name="Type" HorizontalAlignment="Left" Margin="83,41,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<Button x:Name="calc" Content="Показать данные" HorizontalAlignment="Left" Margin="10,75,0,0" VerticalAlignment="Top" Width="212" Style="{StaticResource ButtonStyle}" Click="calc_Click"/>
</Grid>
</Window>
public partial class Movies : Window
{
public Movies()
{
InitializeComponent();
List<Movie> movies = new List<Movie>()
{
new Movie("Охотники за головами", 0, 100000000, 200000000, 40000000, 10000000, 55, 10),
new Movie("Сумерки", 0, 160000000, 300000000, 60000000, 20000000, 50, 11),
new Movie("Подземелье", 1, 6000000, null, 22000000, 2000000, null, null),
new Movie("Заложники Юпитера", 1, 11000000, null, 4000000, 600000, null, null)
};
movieList.ItemsSource = movies;
movieList.DisplayMemberPath = "Name";
}
private void calc_Click(object sender, RoutedEventArgs e)
{
if (movieList.SelectedIndex != -1)
{
Movie movie = ((Movie)movieList.SelectedItem);
switch (movie.Type)
{
case 0:
Film film = new Film(movie);
film.ShowDialog();
break;
default:
Serial serial = new Serial(movie);
serial.ShowDialog();
break;
}
}
else
{
MessageBox.Show("Выберите кинофильм из списка");
}
}
private void movieList_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
if (((Movie)movieList.SelectedItem).Type == 0)
Type.Content = "Фильм";
else
Type.Content = "Сериал";
}
}
Первые два экземпляра класса Movie являются фильмами с полным набором параметров, последние два — сериалами, у которых отсутствуют данные, связанные с кинопрокатом.
Сама форма будет выглядеть следующим образом:
После выбора элемента из списка по нажатию на кнопку должно открыться соответствующее окно, содержащее данные по фильму.
Для проведения основных расчётных операций и внесения изменений в список создадим новый абстрактный класс под названием MovieEdit, наследуемый от Window, который будет описывать общую логику расчёта прибыли и манипуляций с объектами. Это и есть наша абстрактная форма. Она не имеет визуального представления, а лишь содержит общие методы для работы с классом Movie независимо от категории и параметров:
public class MovieEdit : Window
{
protected Movie movie;
protected void calculate(double cost, double cash, string type)
{
double result = (cash - cost) / 1000000;
if (result > 0)
{
MessageBox.Show("Доход от " + type + " "" + Title + "":n" + result + " млн.");
}
else
{
MessageBox.Show("Убыток " + type + " "" + Title + "":n" + -result + " млн.");
}
}
protected void save(int Cost, int? Dues, int DuesTV, int DuesExtra, short? CinemaPart, short? DistrPart)
{
MessageBoxResult view = MessageBox.Show("Сохранить изменения?", "Подтверждение",
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (view == MessageBoxResult.Yes)
{
movie.Cost = Cost;
if (Dues != null) movie.Dues = (int)Dues;
if (CinemaPart != null) movie.CinemaPart = (short)CinemaPart;
if (DistrPart != null) movie.DistrPart = (short)DistrPart;
movie.DuesTV = DuesTV;
movie.DuesExtra = DuesExtra;
Close();
}
}
protected void cancel()
{
MessageBoxResult view = MessageBox.Show("Отменить изменения?", "Подтверждение",
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (view == MessageBoxResult.Yes)
{
Close();
}
}
}
Теперь создадим две формы для показа статистики по фильмам, отличающиеся категорией: отдельная форма для фильмов, отдельная для сериалов. Каждая из них будет содержать три кнопки: Рассчитать, Сохранить и Отменить. При этом наследоваться они будут уже не от Window, а от нашего абстрактного класса MovieEdit, чтобы иметь возможность использовать созданные ранее методы.
<local:MovieEdit x:Class="Earnings.Film"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Earnings"
mc:Ignorable="d" Height="250" Width="310" Style="{StaticResource WindowStyle}" ResizeMode="NoResize" WindowStartupLocation="CenterScreen">
<Grid>
<Label x:Name="_cost" Content="Бюджет:" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="cost" HorizontalAlignment="Left" Height="23" Margin="132,12,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="150"/>
<Label x:Name="_dues" Content="Кассовые сборы:" HorizontalAlignment="Left" Margin="10,36,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="dues" HorizontalAlignment="Left" Height="23" Margin="132,38,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="150"/>
<Label x:Name="_cinemaPart" Content="Процент выручки кинотеатров:" HorizontalAlignment="Left" Margin="10,62,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="cinemaPart" HorizontalAlignment="Left" Height="23" Margin="232,64,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="50"/>
<Label x:Name="_distrPart" Content="Процент выручки дистрибьютора:" HorizontalAlignment="Left" Margin="10,88,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="distrPart" HorizontalAlignment="Left" Height="23" Margin="232,90,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="50"/>
<Label x:Name="_duesTV" Content="Сборы с ТВ:" HorizontalAlignment="Left" Margin="10,114,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="duesTV" HorizontalAlignment="Left" Height="23" Margin="132,116,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="150"/>
<Label x:Name="_duesExtra" Content="Сборы с проката:" HorizontalAlignment="Left" Margin="10,140,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="duesExtra" HorizontalAlignment="Left" Height="23" Margin="132,142,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="150"/>
<Button x:Name="_calc" Content="Рассчитать" HorizontalAlignment="Left" Margin="10,175,0,0" VerticalAlignment="Top" Width="86" Style="{StaticResource ButtonStyle}" Height="25" Click="_calc_Click"/>
<Button x:Name="_save" Content="Сохранить" HorizontalAlignment="Left" Margin="103,175,0,0" VerticalAlignment="Top" Width="86" Style="{StaticResource ButtonStyle}" Height="25" Click="_save_Click"/>
<Button x:Name="_cancel" Content="Отменить" HorizontalAlignment="Left" Margin="196,175,0,0" VerticalAlignment="Top" Width="86" Style="{StaticResource ButtonStyle}" Height="25" Click="_cancel_Click"/>
</Grid>
</local:MovieEdit>
На каждую кнопку поставим обработчик и внутри него вызовем соответствующие функции базового класса, передав нужные параметры:
public partial class Film : MovieEdit
{
public Film(Movie movie)
{
InitializeComponent();
this.movie = movie;
base.Title = movie.Name;
cost.Text = movie.Cost.ToString();
dues.Text = movie.Dues.ToString();
cinemaPart.Text = movie.CinemaPart.ToString();
distrPart.Text = movie.DistrPart.ToString();
duesTV.Text = movie.DuesTV.ToString();
duesExtra.Text = movie.DuesExtra.ToString();
}
private void _calc_Click(object sender, RoutedEventArgs e)
{
base.calculate(double.Parse(cost.Text),
double.Parse(dues.Text) * (100 - double.Parse(cinemaPart.Text) - double.Parse(distrPart.Text)) / 100
+ double.Parse(duesTV.Text) + double.Parse(duesExtra.Text), "фильма");
}
private void _save_Click(object sender, RoutedEventArgs e)
{
base.save(int.Parse(cost.Text), int.Parse(dues.Text), int.Parse(duesTV.Text),
int.Parse(duesExtra.Text), short.Parse(cinemaPart.Text), short.Parse(distrPart.Text));
}
private void _cancel_Click(object sender, RoutedEventArgs e)
{
base.cancel();
}
}
Также необходимо внести поправки в разметку конструктора: вместо тега Window верхнего уровня прописываем наш класс MovieEdit. Иначе возникнет ошибка сборки: визуальная часть и код формы должны наследоваться от одного класса, так как являются составными частями одного элемента.
<local:MovieEdit x:Class="Earnings.Film"
...
xmlns:local="clr-namespace:Earnings"
...>
<Grid>
...
</Grid>
</local:MovieEdit>
Для второй формы проделываем те же действия:
<local:MovieEdit x:Class="Earnings.Serial"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Earnings"
mc:Ignorable="d" Height="172" Width="310" Style="{StaticResource WindowStyle}" ResizeMode="NoResize" WindowStartupLocation="CenterScreen">
<Grid>
<Label x:Name="_cost" Content="Бюджет:" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="cost" HorizontalAlignment="Left" Height="23" Margin="132,12,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="150"/>
<Label x:Name="_duesTV" Content="Сборы с ТВ:" HorizontalAlignment="Left" Margin="10,36,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="duesTV" HorizontalAlignment="Left" Height="23" Margin="132,38,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="150"/>
<Label x:Name="_duesExtra" Content="Сборы с проката:" HorizontalAlignment="Left" Margin="10,62,0,0" VerticalAlignment="Top" Style="{StaticResource LabelStyle}"/>
<TextBox x:Name="duesExtra" HorizontalAlignment="Left" Height="23" Margin="132,64,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="150"/>
<Button x:Name="_calc" Content="Рассчитать" HorizontalAlignment="Left" Margin="10,97,0,0" VerticalAlignment="Top" Width="86" Style="{StaticResource ButtonStyle}" Height="25" Click="_calc_Click"/>
<Button x:Name="_save" Content="Сохранить" HorizontalAlignment="Left" Margin="103,97,0,0" VerticalAlignment="Top" Width="86" Style="{StaticResource ButtonStyle}" Height="25" Click="_save_Click"/>
<Button x:Name="_cancel" Content="Отменить" HorizontalAlignment="Left" Margin="196,97,0,0" VerticalAlignment="Top" Width="86" Style="{StaticResource ButtonStyle}" Height="25" Click="_cancel_Click"/>
</Grid>
</local:MovieEdit>
public partial class Serial : MovieEdit
{
public Serial(Movie movie)
{
InitializeComponent();
base.Title = movie.Name;
cost.Text = movie.Cost.ToString();
duesTV.Text = movie.DuesTV.ToString();
duesExtra.Text = movie.DuesExtra.ToString();
}
private void _calc_Click(object sender, RoutedEventArgs e)
{
base.calculate(double.Parse(cost.Text), double.Parse(duesTV.Text) + double.Parse(duesExtra.Text), "сериала");
}
private void _save_Click(object sender, RoutedEventArgs e)
{
base.save(int.Parse(cost.Text), null, int.Parse(duesTV.Text),
int.Parse(duesExtra.Text), null, null);
}
private void _cancel_Click(object sender, RoutedEventArgs e)
{
base.cancel();
}
}
Вся необходимая информация в наличии, можно тестировать проект. Теперь программа автоматически определяет, какую форму нужно открыть. Выбираем любой элемент из списка, жмём «Показать данные», рассчитываем прибыль по введённым параметрам и видим соответствующий результат:
Итог: как видим, нам удалось создать две формы с одинаковым поведением, но разным представлением, и при этом избежать дублирования кода. Вся логика работы с данными укладывается в три метода в чуть более чем 30 строк. Теперь для изменения реализации достаточно внести корректировку в конкретный абстрактный метод без необходимости править каждую форму по отдельности. Данный пример наглядно демонстрирует преимущества абстрактных классов.
В реальной практике формы, конечно же, не будут такими скромными. Например, на данный момент я разрабатываю две формы для редактирования заявок, каждая из которых содержит пару десятков полей и почти столько же кнопок для загрузки и скачивания файлов из базы. Естественно, приходится постоянно что-то добавлять и вносить поправки, а если после каждого изменения копировать код из одной формы в другую, закончить придётся не раньше следующего года. Уж лучше потратить это время на более полезные вещи, не правда ли?)
Исходники проекта можно найти по адресу.
Автор: Lovk4ch