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

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

Исходники проекта можно найти по адресу.
Поделиться с друзьями
-->

Комментарии (10)


  1. MonkAlex
    08.08.2016 08:49
    +7

    Логика в формах. Я бы не учил такому джуниоров. MVVM в WPF — основа, которой надо учить сразу.


  1. vladimirkolyada
    08.08.2016 09:21
    +6

    1. Соблюдайте общепринятый стиль кода;
    2. Почитайте и изучите что такое MVC, MVP, MVVM;
    3. 99% что с IoC те же проблемы у вас.

    Вывод: Эта статья о том, как делать не надо.


  1. oxidmod
    08.08.2016 10:31

    а что вы будете делать, когда появится 3 тип фильмов с другой логикой подсчетов?
    или в существующих произойдет изменение для сериалов, но не для фильмов?


    1. Lovk4ch
      08.08.2016 11:12

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


  1. SerP1983
    08.08.2016 10:53
    +2

    Плохо. Если бы вы использовали data binding, то очень сильно удивились бы, сколько лишнего и кривого кода вы понаписали. Наверно, после таких вот архитектур и появляются статьи про то, что ООП уже не торт.


    1. Lovk4ch
      08.08.2016 11:22
      -1

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


      1. SerP1983
        08.08.2016 13:25

        Честное слово, вы сами себе придумали «главную проблему» и попытались её героически решить. Но тут нет никаких проблем, все решается стандартно. То, что вы вынесли в базовый класс, можно было бы сделать по-другому. Так то, что вы назвали «сохранением», решается стандартно из коробки через биндинги. Для расчета достаточно было добавить ещё одно св-во класса, которое бы и производило этот расчет, вывести это поле сразу на форму, а не заставлять бедного пользователя нажимать кнопки и закрывать после этого модальные окна. Могу посоветовать вам почитать про концепцию бизнес-объекта в книге Р. Лотка «c# и CSLA.Net»


  1. lair
    08.08.2016 11:13
    +3

    Обозначения параметров:

    Name — название картины
    Type — тип, 0 — фильм, 1 — сериал
    Cost — суммарный бюджет
    Dues — прибыль с кинопроката
    DuesTV — прибыль с телевидения
    DuesExtra — доп. прибыль (DVD, прокаты)
    CinemaPart — доля кинотеатров от прибыли
    DistrPart — доля дистрибьюторов

    ООП? Нет, не слышал… Интересно, вы правда не понимаете, что это плохая модель предметной области?


    А в остальном — как вам уже неоднократно сказали, для подобных задач используется группа шаблонов Separated Presentation.


  1. KvanTTT
    08.08.2016 12:05

    С таким же успехом можно был использовать WinForms, зачем WPF без биндингов? И разметка тяжело читается, т.к. лапша.


  1. Misiam
    08.08.2016 12:20
    +1

    1)

    Type — тип, 0 — фильм, 1 — сериал

    private void movieList_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
    {
    if (((Movie)movieList.SelectedItem).Type == 0)
    Type.Content = «Фильм»;
    else
    Type.Content = «Сериал»;
    }

    Тут очень не хватает enum для типа, converter вместо обработчика и switch с исключением по умолчанию, а то добавится еще «Мультфильм» c Type==2, а пользователь всё равно будет видеть «Сериал».

    2)
    На вкус и цвет именование у всех разное, но всё же
    void calculate(… )
    и
    void _save_Click(… )
    Смотрится несколько странно. Особенно вкупе с классами из самого .net. Вот тут «бинго» из трёх стилей на три строки кода:
        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);
        }
    

    3)
    Собственно то, что уже писали, логику во View держать не надо. Там вообще ни одной строчки, относящейся к вычислениям, не должно быть.
    И не надо будет заморачиваться с теми же конвертами string в int — wpf всё за вас сделает через биндинги.