Привет, Хабр! Сегодня я хочу поделиться небольшим, но очень практичным ноу-хау, к которому мы пришли при разработке крупной распределенной SCADA/HMI-системы для реального завода.

Если вы когда-нибудь работали с классическим каркасом CMFCRibbonBar в C++/MFC, то наверняка знаете официальную позицию Microsoft по поводу разграничения прав доступа. Документация MSDN и тонны учебников в один голос твердят: «Используйте стандартные обработчики ON_UPDATE_COMMAND_UI! Если у пользователя нет прав на действие — делайте кнопку серой и недоступной».

Но давайте спустимся с небес теоретического программирования на землю реального производства. Представьте интерфейс диспетчера, в котором больше 10 вкладок («Цех варки», «Цех упаковки», «Справочники», «Отчеты», «Администрирование пользователей») и сотни кнопок. Если к терминалу подходит обычный рабочий линии, перед ним открывается гигантское «кладбище мертвых серых иконок». Интерфейс перегружен, оператор путается в визуальном шуме, а полезная площадь экрана (которая в промышленных мониторах на вес золота) расходуется вхолостую.

Мы решили пойти другим путем: полностью вырезать интерфейс на основе ролевой модели пользователя прямо «на лету». То есть директор завода видит все 10 вкладок, а варщик у котла — строго одну вкладку «Котлы». Никакого лишнего шума.

В чем коварство стандартного удаления в MFC?

Казалось бы, задача тривиальная. Загружаем Ribbon из XML-ресурса, бежим циклом по категориям и удаляем ненужные через метод RemoveCategory(index).

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

// ТАК ДЕЛАТЬ НЕЛЬЗЯ!
for (int i = 0; i < m_wndRibbonBar.GetCategoryCount(); i++) {
    if (НужноУдалить(i)) {
        m_wndRibbonBar.RemoveCategory(i);
    }
}

Как только этот код удаляет, например, 2-ю категорию, стоявшая за ней 3-я категория автоматически сдвигается влево и становится 2-й! Ваш цикл for инкрементирует счетчик i++, переходит к индексу 3 и… благополучно проскакивает сместившуюся вкладку. В лучшем случае интерфейс удалится наполовину, в худшем — приложение упадет с ошибкой Out of Range (Access Violation).

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

Наше Ноу-Хау: Паттерн "Dynamic Ribbon Pruning"

Мы объединили динамическую перезагрузку RibbonBar из родных .rc ресурсов с хитрым «затвором» на базе цикла do-while.

Полный интерфейс
Полный интерфейс

Алгоритм работает по принципу «умного шага»: если категория удаляется, индекс обхода остается на месте, а верхняя граница контейнера пересчитывается заново. Если категория сохраняется — индекс делает шаг вперед.

Укороченный интерфейс с общедоступными вкладками и специальными вкладками
Укороченный интерфейс с общедоступными вкладками и специальными вкладками

Вот как выглядит этот отказоустойчивый конвейер:

void CMainFrame::UpdateUserRibbonPermissions()
{
    // 1. Полностью сбрасываем и перерождаем Ribbon из базовых ресурсов .exe
    m_wndRibbonBar.RemoveAllCategories();
    m_wndRibbonBar.RemoveAllFromTabs();
    m_wndRibbonBar.LoadFromResource(IDR_RIBBON);
    m_wndRibbonBar.RecalcLayout();

    // 2. Входим в многопоточную критическую секцию
    // Атомарно копируем маску прав текущего пользователя в локальный буфер. 
    // Нам нельзя держать указатель на m_pCurrentUser на свободе, чтобы исключить Data Race 
    // с фоновыми потоками сетевого обмена СУБД.
    UINT uiLocalPermission = 0;
    bool bUserIsLogged = false;
    {
        std::lock_guard<std::mutex> mapLock(m_mtxDB);
        if (m_pCurrentUser) {
            bUserIsLogged = true;
            uiLocalPermission = m_pCurrentUser->iPermition;
        }
    }

    int nCategoryCount = m_wndRibbonBar.GetCategoryCount();
    int nCategoryNdx = 0;
    bool bCategoryWasRemoved;

    // 3. Запускаем наш конвейер точечного вырезания вкладок
    do
    {
        bCategoryWasRemoved = false;
        CMFCRibbonCategory* pmrc = m_wndRibbonBar.GetCategory(nCategoryNdx);
        if (!pmrc) break;

        CString strCategoryName = pmrc->GetName();

        // Побитово проверяем маску прав на основе локального буфера
        if (strCategoryName == TAB_TABLES) // Вкладка "Таблицы"
        {
            if (bUserIsLogged && (((uiLocalPermission >> 4) & 1) == false)) {
                m_wndRibbonBar.RemoveCategory(nCategoryNdx); bCategoryWasRemoved = true;
            }
        }
        else if (strCategoryName == TAB_GRAPHS) // Вкладка "Графики"
        {
            if (bUserIsLogged && (((uiLocalPermission >> 5) & 1) == false)) {
                m_wndRibbonBar.RemoveCategory(nCategoryNdx); bCategoryWasRemoved = true;
            }
        }
        else if (strCategoryName == TAB_SPRAV) // Вкладка "Справочники"
        {
            if (bUserIsLogged && (((uiLocalPermission >> 6) & 1) == false)) {
                m_wndRibbonBar.RemoveCategory(nCategoryNdx); bCategoryWasRemoved = true;
            }
        }
        // ... аналогично проверяем остальные вкладки цехов и отчетов ...

        // КЛЮЧЕВОЙ МОМЕНТ ХЭНДЛИНГА ИНДЕКСОВ:
        if (!bCategoryWasRemoved)
            nCategoryNdx++; // Шаг вперед делаем только если элемент НЕ удалялся!
        else
            nCategoryCount = m_wndRibbonBar.GetCategoryCount(); // Иначе пересчитываем размер "на лету"

    } while (nCategoryNdx < nCategoryCount);

    // 4. Эстетический WinAPI-хак: Полностью убираем круглую кнопку меню (Application Button)
    // Она перерождается при каждом LoadFromResource, поэтому гасим её прямо здесь.
    CMFCRibbonApplicationButton* pAppBtn = m_wndRibbonBar.GetApplicationButton();
    if (pAppBtn != nullptr)
    {
        pAppBtn->SetVisible(FALSE);
        m_wndRibbonBar.SetApplicationButton(NULL, 0); // Передача пустой строки схлопывает левые отступы
    }

    // Финальный пересчет геометрии окон. Вкладки доступных цехов плавно сдвигаются в самый левый край.
    m_wndRibbonBar.ForceRecalcLayout();
}

Архитектурные итоги

Что мы получили в результате внедрения этого подхода на 30–50 параллельных пользователях в наших проектах?

  • Идеальный UX/UI: Интерфейс приложения мгновенно адаптируется под конкретного человека в момент логина. Рабочий видит только свои кнопки управления, технолог — свои графики варок, бухгалтер — свои отчеты. Никаких «серых кладбищ».

  • Абсолютная потокобезопасность: Благодаря выносу проверки прав из-под мьютекса через локальный примитив uiLocalPermission, графический интерфейс переключается плавно, не вызывая дедлоков (Deadlock) и не мешая фоновым потокам SCADA ежесекундно опрашивать контроллеры по сети.

  • Скорость работы: Операция пересчета всего Риббона занимает доли миллисекунды, так как выполняется в ОЗУ, не нагружая процессор и сетевые сокеты СУБД.

Иногда, чтобы сделать интерфейс промышленной программы по-настоящему удобным и надежным, не нужно внедрять тяжелые сторонние движки. Достаточно использовать базовые возможности WinAPI, капельку математической логики и правильное распределение потоков памяти.

А как вы решаете вопросы динамических прав в десктопных приложениях? Поделитесь в комментариях!