Собеседование Android system design
Сегодня мы разберём архитектурное собеседование, на котором кандидат проектировал систему совместного редактирования документов для мобильного приложения. Интервьюер и кандидат обсуждали архитектуру приложения, включая слои приложения, пагинацию, редактирование текста и стилей, работу с сетью и локальным хранилищем, а также паттерны проектирования, такие как MVVM и чистая архитектура. Кандидат продемонстрировал глубокие знания платформы и лучших практик, однако интервьюер отметил, что ему не хватало системности в решении задач и умения разбивать сложные проблемы на более мелкие составляющие. В ходе обсуждения были затронуты важные аспекты, такие как обработка конфликтов при совместном редактировании, оптимизация обновлений интерфейса и выбор формата для стилизации текста, а также выявлены области для улучшения, включая более тщательное планирование и проработку сценариев использования.
Вопрос 1. Какой у вас опыт работы в Android-разработке и проектировании решений?
Таймкод: 00:03:35
Ответ собеседника: Правильный. Андрей работает продуктовым разработчиком 2 года в Delivery Club, общий опыт в Android-разработке — около 5 лет. В продуктовой команде занимается проектированием решений для крупных задач, совместной работой с другими Android-разработчиками и выработкой архитектурных решений. Также ранее проходил System Design собеседования в нескольких компаниях, включая Яндекс и зарубежные компании.
Правильный ответ:
Ответ кандидата является корректным и достаточным для описания опыта. Вот как можно структурировать и дополнить такой ответ для полноты:
Опыт в Android-разработке
Стоит уточнить стек технологий, с которым работал кандидат:
- Языки: Kotlin (основной), Java (legacy-код)
- Архитектурные подходы: MVVM, MVI, Clean Architecture
- Библиотеки и фреймворки: Jetpack Compose / View System, Retrofit, Room, Dagger/Hilt, Coroutines/Flow, Navigation Component
- Тестирование: JUnit, MockK, Espresso, UI-тесты
Проектирование решений
Для демонстрации зрелости в проектировании полезно описать конкретные примеры:
- Разбиение крупных фич на модули (feature-модули, core-модули) с учётом командной работы
- Проектирование API между слоями приложения (data-domain-presentation)
- Принятие решений по работе с состоянием экрана (StateFlow, SharedFlow, единый State Holder)
- Выбор стратегии навигации при масштабировании приложения (single-activity vs multi-module navigation)
- Внедрение CI/CD-процессов, статического анализа (detekt, ktlint), code review практик
System Design опыт
Прохождение System Design собеседований говорит о способности мыслить на уровне системы, а не только отдельного экрана или модуля. Это включает:
- Оценку ёмкости и нагрузки (capacity estimation)
- Проектирование клиент-серверного взаимодействия (REST/GraphQL, кэширование, offline-first)
- Выбор стратегии хранения данных (локальная БД, кэш, синхронизация)
- Балансировку между производительностью, надёжностью и скоростью разработки
Данный ответ демонстрирует зрелый уровень кандидата с опытом от 5 лет, способного не только писать код, но и принимать архитектурные решения в продуктовой команде.
Вопрос 2. В каких компаниях проводят System Design собеседования для Android-разработчиков?
Таймкод: 00:04:33
Ответ собеседника: Правильный. В России не так много компаний, проводящих System Design. Кандидат проходил такое собеседование в Яндексе, а также в некоторых зарубежных компаниях. За рубежом System Design — обязательная секция на уровне алгоритмического интервью, особенно в крупных компаниях.
Правильный ответ:
Ответ кандидата корректен. Вот более полная картина по компаниям и рынку.
Крупные зарубежные компании (FAANG+ и аналоги)
System Design — стандартная часть процесса для позиций уровня Mid+ и выше:
- Google — проектирование клиент-серверных систем, оффлайн-режим, синхронизация данных
- Meta (Facebook/Instagram) — масштабируемые фиды, real-time обновления, архитектура приложений
- Amazon — проектирование сервисов с учётом AWS-инфраструктуры, кэширование, очереди
- Apple — проектирование систем с фокусом на privacy, on-device processing, энергопотребление
- Uber, Airbnb, Spotify — специфичные для домена задачи (геолокация, потоковое видео, real-time matching)
- Netflix — проектирование видеостриминга, A/B тестирование, клиентская архитектура
Российские компании
В России System Design пока не так распространён, но тренд нарастает:
- Яндекс — проводит System Design для Android-разработчиков, особенно в крупных сервисах (Такси, Маркет, Поиск)
- VK (ранее Mail.ru Group) — внедряет элементы системного проектирования для позиций уровня Senior+
- Тинькофф — использует кейс-интервью с элементами проектирования архитектуры
- Ozon, Wildberries — задачи на проектирование масштабируемых модулей в e-commerce приложениях
- Сбер (СберДевайсы, Сбербанк Онлайн) — архитектурные секции для senior-позиций
Формат Android-specific System Design
На таких собеседованиях типичные задачи включают:
- Проектирование архитектуры новостной ленты (аналог Instagram/Facebook Feed)
- Проектирование оффлайн-режима для приложения с синхронизацией
- Проектирование системы push-уведомлений с учётом доставки и приоритизации
- Проектирование модульной архитектуры для команды из N разработчиков
- Проектирование системы кэширования изображений/данных на клиенте
- Проектирование SDK или библиотеки для внутреннего использования
Тренд на рынке
Если раньше в России System Design считался уделом backend-разработчиков, то сейчас крупные компании всё чаще включают эту секцию для Android/iOS-разработчиков, особенно для позиций Senior и выше. Это связано с усложнением мобильных приложений и необходимостью мыслить на уровне системы, а не отдельного экрана.
Вопрос 3. Как вы представляете себе процесс System Design собеседования и что на нём проверяется?
Таймкод: 00:08:13
Ответ собеседника: Правильный. Основная задача — оценить знания кандидата, в каких моментах он разбирается, а в каких — нет. Важно понять, как кандидат коммуницирует, как принимает решения, как реагирует на замечания и подсказки. Также важно оценить, как кандидат в целом проектирует систему, потому что это реальный рабочий процесс, когда люди вырабатывают архитектуру для последующей реализации.
Правильный ответ:
Ответ кандидата точно отражает суть System Design собеседования. Вот более структурированное описание процесса и критериев оценки.
Структура типичного System Design интервью (45–60 минут)
1. Уточнение требований (5–10 минут)
Первый и критически важный этап. Кандидат должен:
- Задать уточняющие вопросы о функциональных требованиях (что именно нужно построить)
- Выять нефункциональные требования (производительность, надёжность, масштабируемость)
- Оценить масштаб: количество пользователей, RPS, объём данных
- Определить ограничения: бюджет, сроки, команда, legacy
Типичная ошибка — сразу начинать проектирование без уточнения требований. Интервьюер ожидает, что кандидат сначала поймёт проблему, а потом предложит решение.
2. Высокоуровневое проектирование (10–15 минут)
На этом этапе кандидат рисует архитектуру «с высоты птичьего полёта»:
- Определяет основные компоненты системы (клиент, API Gateway, сервисы, БД, кэш, очереди)
- Описывает потоки данных между компонентами
- Выбирает базовые технологические решения с обоснованием
Для Android-specific задач это может быть:
- Архитектура клиент-серверного приложения
- Стратегия оффлайн-режима и синхронизации
- Структура модулей на клиенте
3. Детальное проектирование (15–20 минут)
Углубление в ключевые компоненты:
- Декомпозиция на модули/слои
- Выбор паттернов проектирования
- Проектирование API (контракты между клиентом и сервером)
- Стратегии кэширования, хранения данных
- Обработка ошибок и edge-кейсов
4. Обсуждение ограничений и улучшений (5–10 минут)
- Анализ узких мест (bottleneck analysis)
- Обсуждение того, что изменится при росте нагрузки в 10x, 100x
- Предложение альтернативных подходов
- Обсуждение trade-offs принятых решений
Что проверяется
Технические компетенции:
- Понимание архитектурных паттернов (MVC, MVVM, MVI, Clean Architecture, Modular Architecture)
- Знание сетевых протоколов, стратегий кэширования, работы с данными
- Понимание масштабирования, балансировки нагрузки, репликации
- Знание специфики мобильной разработки (ограничения устройств, работа с сетью, энергопотребление)
Soft skills:
- Коммуникация: способность объяснять решения и слушать обратную связь
- Структурное мышление: разбиение сложной задачи на части
- Гибкость: готовность менять подход при получении новой информации
- Уверенность в решениях при этом открытость к альтернативам
Процессные навыки:
- Умение работать с неполной информацией и делать обоснованные допущения
- Приоритизация: что делать первым, что можно отложить
- Оценка сложности и сроков реализации
Критерии оценки интервьюером
Интервьюер оценивает не столько «правильный ответ» (его часто нет), сколько:
- Как кандидат рассуждает и приходит к решениям
- Насколько глубоко он понимает последствия своих решений
- Как реагирует на направляющие вопросы и возражения
- Способен ли он trade-off'ить между идеальным и практичным решением
System Design — это действительно модель реального рабочего процесса, где команда совместно вырабатывает архитектуру, и умение вести такой диалог — ключевой навык для senior-разработчика.
Вопрос 4. Знакомы ли вы с документом Mobile System Design от разработчика Google и видели ли вы материалы по этой теме?
Таймкод: 00:08:57
Ответ собеседника: Правильный. Да, знаком с документом Алексея и его интервью по различным темам. Также видел несколько его материалов, включая разборы библиотек.
Правильный ответ:
Ответ кандидата корректен. Вот более полное описание этого материала и связанных ресурсов.
Mobile System Design Doc от Алексея Денисова (Google)
Речь идёт о публичном документе, который стал де-факто стандартом подготовки к Mobile System Design собеседованиям. Документ описывает структуру и подход к проектированию мобильных приложений на системном уровне.
Ключевые идеи документа:
- Фреймворк проектирования: пошаговый подход к разбору задачи — от требований до детальной архитектуры
- Разделение на слои: клиентская архитектура (presentation, domain, data), серверная часть, взаимодействие между ними
- Типичные компоненты: feature-модули, core-модули, navigation, dependency injection, networking layer
- Offline-first стратегии: проектирование приложений с поддержкой работы без сети и синхронизацией
- Масштабирование команды: как архитектура должна учитывать параллельную работу нескольких разработчиков
Связанные материалы и ресурсы
Помимо самого документа, полезно знать следующие источники:
- YouTube-канал Алексея Денисова — разборы конкретных System Design задач, обсуждение архитектурных решений, ревью библиотек
- «Mobile System Design» на GitHub — публичные репозитории с примерами архитектур и шаблонами решений
- Серия интервью с инженерами из FAANG — разбор реальных кейсов проектирования приложений масштаба
- Книга «System Design Interview» (Alex Xu) — хотя ориентирована на backend, даёт фундаментальное понимание масштабирования, кэширования, балансировки нагрузки
- Материалы от других Google-инженеров — статьи и доклады о внутренних практиках проектирования Android-приложений
Практическая ценность документа
Документ полезен не только для подготовки к собеседованиям, но и как рабочий фреймворк:
- Структурирует мышление при проектировании новых фич
- Даёт общий язык для обсуждения архитектуры в команде
- Помогает выявить пробелы в знаниях — если какой-то аспект из документа непонятен, это сигнал к углублению в тему
- Предоставляет чек-лист тем, которые стоит знать для успешного прохождения System Design секции
Знакомство с этим материалом демонстрирует, что кандидат осознанно подходит к профессиональному развитию и понимает стандарты индустрии в области архитектурного проектирования.
Вопрос 5. Насколько вы уверенно чувствуете себя в рисовании архитектурных диаграмм (квадратиков и стрелочек)?
Таймкод: 00:10:12
Ответ собеседника: Правильный. Кандидат подтвердил, что чувствует себя уверенно в рисовании диаграмм и понимает, как правильно визуализировать архитектурные решения.
Правильный ответ:
Умение визуализировать архитектуру — критически важный навык для успешного прохождения System Design собеседования. Вот что стоит знать и уметь.
Зачем нужны диаграммы на собеседовании
- Структурирование мышления: рисование помогает кандидату самому упорядочить идеи
- Коммуникация с интервьюером: визуальная модель позволяет обоим участникам находиться на одной странице
- Демонстрация зрелости: умение абстрагироваться от деталей и показать систему на нужном уровне говорит об опыте
- Навигация по обсуждению: диаграмма служит «картой», к которой можно возвращаться при углублении в компоненты
Какие типы диаграмм стоит уметь рисовать
1. High-Level Architecture Diagram
Общая картина системы на уровне компонентов:
- Клиент (мобильное приложение, веб)
- API Gateway / Load Balancer
- Микросервисы или монолит
- Базы данных, кэш, очереди
- Внешние интеграции (платёжные системы, push-провайдеры, аналитика)
2. Component Diagram (клиентская архитектура)
Детализация устройства мобильного приложения:
- Presentation Layer (View/Activity/Fragment/Composable + ViewModel/Presenter)
- Domain Layer (Use Cases, Entities, Business Logic)
- Data Layer (Repositories, Data Sources, Mappers)
- Core/Common модули (Networking, DI, Navigation, UI Components)
3. Sequence Diagram
Показывает порядок взаимодействия компонентов:
- Пользователь выполняет действие
- Клиент отправляет запрос
- Сервер обрабатывает и отвечает
- Клиент обновляет UI
- Особенно полезна для обсуждения оффлайн-режима, retry-логики, синхронизации
4. Data Flow Diagram
Отображает путь данных через систему:
- Откуда данные приходят (API, локальная БД, кэш, пользовательский ввод)
- Как трансформируются (маппинг, фильтрация, агрегация)
- Где хранятся (кеш в памяти, SharedPreferences, Room, файловая система)
- Как отображаются пользователю
5. Module Dependency Diagram
Для обсуждения модульной архитектуры:
- Как приложение разбито на модули
- Зависимости между модулями
- Публичные API каждого модуля
- Как это масштабируется при росте команды
Инструменты для рисования
На собеседовании обычно используется:
- Белаядоска (физическая или виртуальная — Miro, FigJam, Excalidraw)
- Блокнот с ручкой (на очных собеседованиях)
- Текстовое описание с псевдо-диаграммами (если нет возможности рисовать)
Для подготовки полезно практиковаться в:
- Excalidraw — простой онлайн-инструмент с «рукописным» стилем
- draw.io (diagrams.net) — бесплатный инструмент для формальных диаграмм
- Miro — виртуальная доска, популярная на удалённых собеседованиях
- Mermaid — текстовый формат для диаграмм (удобно для документации)
Практические советы по рисованию на собеседовании
- Начинайте с высокого уровня, затем углубляйтесь по запросу интервьюера
- Используйте подписи: стрелки должны быть подписаны (что передаётся, какой протокол)
- Не бойтесь стирать и перерисовывать — это нормальная часть процесса
- Согласуйте нотацию с интервьюером: «Я буду использовать прямоугольники для компонентов, стрелки для зависимостей — ок?»
- Оставляйте место: не заполняйте всю доску сразу, оставляйте пространство для добавления компонентов
- Поясняйте рисунок: не просто рисуйте, а проговаривайте, что изображаете и почему
Уверенное владение архитектурными диаграммами — это навык, который напрямую влияет на оценку кандидата, поскольку он демонстрирует способность мыслить системно и коммуницировать сложные идеи наглядно.
Вопрос 6. Какой формат System Design собеседования ожидается: какие этапы и что важнее — процесс или результат?
Таймкод: 00:10:50
Ответ собеседника: Правильный. Кандидат представлял процесс следующим образом: сначала сбор требований, затем верхнеуровневая схема компонентов без связей, потом углубление в детали. Интервьюер подтвердил, что процесс важнее результата — главное быть честными друг с другом, давать обратную связь и двигаться по процессу. Время ограничено, поэтому к идеальному результату прийти может не получиться.
Правильный ответ:
Ответ кандидата полностью корректен и демонстрирует правильное понимание формата. Вот детальное описание процесса и приоритетов.
Типичный формат System Design собеседования
Этап 1: Введение и формулировка задачи (2–3 минуты)
Интервьюер описывает задачу в общих чертах, например: «Спроектируй приложение для заказа еды» или «Спроектируй систему чата, похожую на WhatsApp». На этом этапе важно не перебивать и выслушать полную формулировку.
Этап 2: Сбор требований (5–10 минут)
Кандидат задаёт уточняющие вопросы:
- Какие платформы? (Android, iOS, Web, кроссплатформа)
- Сколько пользователей? Какой трафик?
- Какие функции приоритетны в первой версии?
- Есть ли ограничения по технологиям или инфраструктуре?
- Каковы требования к оффлайн-режиму?
- Есть ли специфика по безопасности или регуляторным требованиям?
Этот этап критически важен — он показывает, умеет ли кандидат работать с неопределённостью и сужать пространство задачи.
Этап 3: Высокоуровневое проектирование (10–15 минут)
Кандидат рисует общую архитектуру:
- Основные компоненты системы
- Взаимодействие между клиентом и сервером
- Хранение данных, кэширование
- Навигация и структура модулей на клиенте
На этом этапе интервьюер может направлять кандидата, предлагая углубиться в определённые области.
Этап 4: Детальное проектирование (15–20 минут)
Углубление в конкретные компоненты по выбору интервьюера:
- Архитектура конкретного экрана или фичи
- Стратегия работы с сетью и ошибками
- Оффлайн-режим и синхронизация
- Кэширование на разных уровнях
- Модульная структура и разделение ответственности
Этап 5: Обсуждение ограничений и улучшений (5–10 минут)
- Что произойдёт при росте нагрузки?
- Какие узкие места в предложенной архитектуре?
- Какие альтернативы существуют?
- Что бы вы сделали иначе, если бы было больше времени?
Процесс vs Результат: что важнее
Интервьюеры в крупных компаниях единодушны: процесс рассуждения важнее конечного результата. Это объясняется несколькими причинами:
- Реальная работа — это процесс: на практике архитектура итеративно обсуждается, пересматривается и дорабатывается командой. Умение вести такой диалог важнее способности за 45 минут нарисовать «идеальную» схему
- Нет единственно правильного ответа: одну и ту же задачу можно решить десятком способов, и каждый будет иметь свои trade-offs
- Оценка коммуникации: интервьюер смотрит, как кандидат объясняет решения, как реагирует на направляющие вопросы, как принимает обратную связь
- Оценка честности: важно признавать, в чём кандидат не уверен, и обсуждать альтернативы, а не делать вид, что знаешь всё
Красные флаги на собеседовании
- Кандидат молча рисует 15 минут, не проговаривая мысли
- Игнорирование направляющих вопросов интервьюера
- Утверждение, что его решение — единственно верное
- Отказ обсуждать альтернативы и trade-offs
- Попытка показать глубину знаний в одной области за счёт игнорирования остальных
Зелёные флаги
- Структурированный подход: от общего к частному
- Проговаривание допущений перед принятием решений
- Готовность менять подход при получении новой информации
- Задавание уточняющих вопросов
- Честное признание: «Я не уверен в этом аспекте, но думаю, что...»
- Обсуждение нескольких вариантов с обоснованием выбора
Кандидат правильно отметил, что за 45–60 минут прийти к идеальному результату невозможно — и это нормально. Интервьюер оценивает траекторию мышления, а не конечную точку.
Вопрос 7. Спроектируйте текстовый редактор с возможностью стилизации текста и коллаборативным редактированием. Какие уточняющие вопросы вы зададите по требованиям?
Таймкод: 00:13:25
Ответ собеседника: Правильный. Кандидат задал ряд уточняющих вопросов: сколько человек может одновременно редактировать текст (ограничений нет, чем больше тем лучше); какие виды стилизации нужны (жирный, курсив, заголовки, цвет, размер шрифта); нужна ли приватность и управление доступом к документам (нужна возможность ограничить доступ по ссылке или конкретному набору людей); нужен ли список документов (да, нужен); нужна ли поддержка оффлайн-редактирования с последующей синхронизацией (да, но это сложная фича для обсуждения позже); стоит ли закладывать масштабируемость сразу (продукт должен работать, задача написать под десятки разработчиков не стоит, но проектирование с учётом масштабирования будет плюсом); нужно ли шифрование документов на диске (можно пока не обсуждать).
Правильный ответ:
Кандидат задал отличные вопросы, которые охватывают ключевые аспекты задачи. Вот полный список уточняющих вопросов, которые стоит задать на таком собеседовании, с категоризацией и обоснованием.
Функциональные требования
Основной функционал:
- Какие форматы стилизации текста нужны? (жирный, курсив, подчёркивание, заголовки, списки, цвет текста, цвет фона, размер шрифта, выравнивание)
- Нужна ли поддержка вставки медиа: изображения, видео, таблицы, ссылки?
- Нужна ли поддержка вложенных структур: таблицы, блоки кода, цитаты?
- Нужны ли горячие клавиши для форматирования?
- Нужна ли история изменений с возможностью отката (undo/redo)?
- Нужна ли автосохранение?
Коллаборативное редактирование:
- Сколько пользователей может одновременно редактировать один документ?
- Нужно ли отображать курсоры/выделения других пользователей в реальном времени?
- Нужен ли чат или комментарии к документу?
- Нужно ли разрешение конфликтов при одновременном редактировании одного фрагмента?
- Нужно ли отображать, кто сейчас просматривает документ (presence)?
Управление документами:
- Нужен ли список/каталог документов?
- Нужна ли папочная структура или теги?
- Нужен ли поиск по документам?
- Нужна ли возможность экспорта (PDF, DOCX, Markdown)?
- Нужна ли возможность шаблонов документов?
Нефункциональные требования
Производительность:
- Какой максимальный размер документа ожидается? (килобайты, мегабайты)
- Какая допустимая задержка при отображении изменений от других пользователей?
- Есть ли требования к времени загрузки документа?
Масштабирование:
- Ожидаемое количество пользователей (DAU/MAU)?
- Ожидаемое количество одновременных сессий редактирования?
- Ожидаемое количество документов?
Надёжность:
- Какова допустимая потеря данных? (нулевая потеря — это другой уровень сложности)
- Нужна ли гарантия доставки изменений в определённом порядке?
Безопасность:
- Нужна ли аутентификация и авторизация?
- Нужно ли разграничение прав: чтение, комментирование, редактирование, администрирование?
- Нужно ли шифрование данных на диске и при передаче?
- Есть ли требования к аудиту действий (кто, когда, что изменил)?
Технические ограничения
Платформы:
- Какие платформы нужно поддерживать? (Android, iOS, Web)
- Нужна ли кросс-платформенная совместимость документов?
- Есть ли требования к минимальной версии Android?
Офлайн-режим:
- Нужна ли поддержка офлайн-редактирования?
- Как разрешаются конфликты при синхронизации после офлайна?
- Нужен ли приоритет: оффлайн-первый или онлайн-первый подход?
Интеграции:
- Нужна ли интеграция с облачными хранилищами (Google Drive, Dropbox)?
- Нужна ли интеграция с системами аутентификации (SSO, OAuth)?
- Нужен ли API для внешних интеграций?
Приоритизация
Важно также спросить:
- Какие функции критичны для MVP?
- Какие функции можно отложить на следующие версии?
- Есть ли жёсткие дедлайны или ограничения по ресурсам?
Вопросы кандидата показали хорошее понимание задачи: он корректно определил scope (десятки разработчиков, не масштабирование на миллионы), уточнил ключевые функции (стилизация, управление доступом, список документов, оффлайн), и обоснованно отложил сложные темы (шифрование). Это демонстрирует зрелый подход к проектированию — умение расставлять приоритеты и не увлекаться избыточной инженерией.
Вопрос 8. Опишите верхнеуровневую архитектуру мобильного приложения текстового редактора. Какие основные компоненты вы выделите?
Таймкод: 00:29:18
Ответ собеседника: Правильный. Кандидат выделил следующие основные компоненты верхнего уровня: два основных Flow — список документов и редактирование документов; Backend как отдельный компонент с возможностью дальнейшей декомпозиции; Network Layer (Network Point) для связи с бэкендом; DI (Dependency Injection) для внедрения зависимостей, что помогает с тестируемостью и масштабируемостью; навигацию между Flow для снижения связанности и возможности замены навигационного фреймворка; стор (хранилище данных) для поддержки оффлайн-режима; репозиторий как абстракцию для работы с несколькими источниками данных (сеть и локальное хранение). Также упомянул компоненты стилизации и шаринга как часть Editor Flow.
Правильный ответ:
Кандидат представил качественную высокоуровневую архитектуру. Вот полная декомпозиция с детализацией каждого компонента.
Высокоуровневая архитектура приложения
1. Presentation Layer (UI)
Documents List Flow:
- DocumentsListScreen — экран со списком документов (поиск, сортировка, фильтрация)
- DocumentsListViewModel — управление состоянием списка, обработка действий пользователя
- DocumentListItem — компонент отображения одного документа в списке
- CreateDocumentDialog — диалог создания нового документа
Editor Flow:
- EditorScreen — основной экран редактирования текста
- EditorViewModel — управление состоянием редактора, координация стилизации
- Toolbar — панель инструментов форматирования (жирный, курсив, заголовки и т.д.)
- RichTextEditor — компонент редактирования текста с поддержкой форматирования
- CollaboratorsPanel — панель отображения активных редакторов
- ShareDialog — диалог настройки доступа к документу
2. Domain Layer (Business Logic)
Use Cases:
- GetDocumentsUseCase — получение списка документов
- CreateDocumentUseCase — создание нового документа
- OpenDocumentUseCase — загрузка документа для редактирования
- SaveDocumentUseCase — сохранение изменений в документе
- ShareDocumentUseCase — управление доступом к документу
- ApplyFormattingUseCase — применение форматирования к выделенному тексту
- SyncDocumentUseCase — синхронизация локальных изменений с сервером
Models:
- Document — модель документа (id, title, content, metadata, permissions)
- DocumentContent — модель содержимого документа с форматированием
- TextRange — диапазон текста для форматирования
- FormattingOptions — параметры форматирования (bold, italic, fontSize, color и т.д.)
- Collaborator — модель участника редактирования (id, name, cursor position, color)
3. Data Layer
Repositories:
- DocumentsRepository — координирует работу с несколькими источниками данных
- SyncRepository — управляет синхронизацией между клиентом и сервером
Data Sources:
- RemoteDataSource — работа с сетью (REST API, WebSocket)
- LocalDataSource — локальное хранение (Room database, DataStore)
Network Layer:
- ApiService — REST API для CRUD операций с документами
- WebSocketService — WebSocket соединение для real-time синхронизации
- NetworkMonitor — мониторинг состояния сети (online/offline)
- RetryHandler — обработка ошибок сети с retry логикой
Local Storage:
- DocumentsDao — Data Access Object для работы с таблицей документов
- PendingChangesDao — хранение локальных изменений, ожидающих синхронизации
- CacheManager — управление кэшем документов в памяти
4. Core Layer (общие компоненты)
DI (Dependency Injection):
- AppModule — модуль приложения (singleton зависимости)
- NetworkModule — модуль сетевого слоя
- DatabaseModule — модуль базы данных
- FeatureModule — модуль фичи (для каждого flow)
Navigation:
- AppNavigator — координатор навигации между экранами
- DeepLinkHandler — обработка глубоких ссылок
Connectivity:
- ConnectivityManager — отслеживание состояния сети
- SyncScheduler — планировщик фоновой синхронизации
5. Backend (серверная часть)
API Gateway:
- Маршрутизация запросов
- Аутентификация и авторизация
- Rate limiting
Сервисы:
- Documents Service — CRUD операции с документами
- Collaboration Service — real-time синхронизация через WebSocket
- Auth Service — аутентификация и авторизация
- Permissions Service — управление правами доступа
Хранение:
- Documents Database — хранение документов (PostgreSQL, MongoDB)
- Cache (Redis) — кэширование активных документов
- Message Queue — очередь для real-time событий
Схема взаимодействия компонентов
┌─────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌─────────────────────┐ ┌──────────────────────────┐ │
│ │ DocumentsListFlow │ │ EditorFlow │ │
│ │ - Screen │ │ - Screen │ │
│ │ - ViewModel │ │ - ViewModel │ │
│ └─────────────────────┘ │ - Toolbar │ │
│ │ - RichTextEditor │ │
│ │ - CollaboratorsPanel │ │
│ └──────────────────────────┘ │
└─────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────────┐
│ Domain Layer │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Use Cases ││
│ │ GetDocuments | CreateDocument | OpenDocument | SaveDocument││
│ │ ShareDocument | ApplyFormatting | SyncDocument ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Models ││
│ │ Document | DocumentContent | TextRange | FormattingOptions ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────────┐
│ Data Layer │
│ ┌───────────────────┐ ┌─────────────────────────────────────┐ │
│ │ Repositories │ │ Data Sources │ │
│ │ DocumentsRepo │ │ ┌─────────────┐ ┌───────────────┐ │ │
│ │ SyncRepo │──│ │ Remote │ │ Local │ │ │
│ └───────────────────┘ │ │ ApiService │ │ DocumentsDao │ │ │
│ │ │ WebSocket │ │ PendingDao │ │ │
│ │ └─────────────┘ └───────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────────┐
│ Backend │
│ ┌──────────┐ ┌────────────────┐ ┌────────────────────────┐ │
│ │API Gateway│ │Collaboration │ │ Documents Service │ │
│ │ │ │Service (WS) │ │ (CRUD) │ │
│ └──────────┘ └────────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Кандидат верно выделил ключевые компоненты: два основных Flow, разделение на слои (Presentation, Domain, Data), Network Layer, DI, Navigation, локальное хранилище и репозиторий как абстракцию над источниками данных. Это демонстрирует хорошее понимание Clean Architecture и умение декомпозировать сложную систему.
Вопрос 9. Детализируйте архитектуру экрана списка документов. Какие компоненты и слои вы выделите, включая пагинацию?
Таймкод: 00:42:16
Ответ собеседника: Правильный. Кандидат детализировал экран списка документов следующим образом: репозиторий для получения списка документов, обращающийся к Network Layer; Use Case (Interactor) — отдельный слой бизнес-логики, инкапсулирующий только бизнес-логику без специфики UI, что легко масштабируется, тестируется и переиспользуется; ViewModel в presentation-слое, обращающийся к Use Case. Для пагинации кандидат предложил добавить отдельный компонент — пагинатор, который хранит ID последней сущности и запрашивает следующую страницу по курсорной пагинации (передаёт ID последнего видимого элемента). Обосновал выбор курсорной пагинации тем, что документы — нестабильная сущность (могут удаляться, меняться), и курсорный подход лучше подходит для таких случаев. Пагинатор выделяется в отдельную сущность по принципу SRP, чтобы не засорять репозиторий логикой работы со страницами.
Правильный ответ:
Кандидат представил отличную детализацию с правильным разделением ответственности и обоснованным выбором курсорной пагинации. Вот полная архитектура экрана списка документов.
Presentation Layer
DocumentsListViewModel:
- Управляет состоянием экрана (StateFlow/SharedFlow)
- Обрабатывает действия пользователя (pull-to-refresh, поиск, создание документа)
- Координирует работу с Use Case и пагинатором
- Преобразует domain-модели в UI-модели
DocumentsListState:
data class DocumentsListState(
val documents: List<DocumentItemUi> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null,
val hasMorePages: Boolean = true,
val searchQuery: String = ""
)
DocumentsListEvent:
sealed class DocumentsListEvent {
object LoadDocuments : DocumentsListEvent()
object RefreshDocuments : DocumentsListEvent()
object LoadMore : DocumentsListEvent()
data class SearchDocuments(val query: String) : DocumentsListEvent()
data class OpenDocument(val documentId: String) : DocumentsListEvent()
object CreateDocument : DocumentsListEvent()
}
Domain Layer
GetDocumentsUseCase:
class GetDocumentsUseCase(
private val documentsRepository: DocumentsRepository
) {
suspend fun getFirstPage(): Result<List<Document>> {
return documentsRepository.getDocuments(firstPage = true)
}
suspend fun refresh(): Result<List<Document>> {
return documentsRepository.refreshDocuments()
}
}
SearchDocumentsUseCase:
class SearchDocumentsUseCase(
private val documentsRepository: DocumentsRepository
) {
suspend fun search(query: String): Result<List<Document>> {
return documentsRepository.searchDocuments(query)
}
}
Data Layer
DocumentsRepository:
class DocumentsRepository(
private val remoteDataSource: DocumentsRemoteDataSource,
private val localDataSource: DocumentsLocalDataSource,
private val networkMonitor: NetworkMonitor
) {
suspend fun getDocuments(
cursor: String? = null,
limit: Int = 20
): Result<PagedResult<Document>> {
return if (networkMonitor.isOnline()) {
remoteDataSource.getDocuments(cursor, limit)
.onSuccess { pagedResult ->
localDataSource.saveDocuments(pagedResult.items)
}
} else {
localDataSource.getDocuments(cursor, limit)
}
}
}
Архитектура пагинации
Cursor-based Pagination (выбранный подход):
Кандидат абсолютно прав в выборе курсорной пагинации. Вот почему она лучше подходит для списка документов:
- Стабильность при изменении данных: при offset-пагинации, если между запросами страницы документ удаляется или добавляется, пользователь может увидеть дубли или пропустить элементы. Курсорная пагинация этого избегает
- Производительность: на больших объёмах данных
OFFSETв базах данных работает медленно, тогда как курсор использует индекс - Реалтайм-обновления: при коллаборативном редактировании документы часто меняются (переименование, изменение доступа), и курсорный подход корректно обрабатывает такие изменения
PaginationManager:
class PaginationManager(
private val repository: DocumentsRepository,
private val config: PaginationConfig = PaginationConfig()
) {
private var currentCursor: String? = null
private var hasMorePages: Boolean = true
private var isLoading: Boolean = false
suspend fun loadNextPage(): Result<List<Document>> {
if (isLoading || !hasMorePages) {
return Result.success(emptyList())
}
isLoading = true
return repository.getDocuments(cursor = currentCursor, limit = config.pageSize)
.onSuccess { pagedResult ->
currentCursor = pagedResult.nextCursor
hasMorePages = pagedResult.hasMore
isLoading = false
}
.onFailure {
isLoading = false
}
}
fun reset() {
currentCursor = null
hasMorePages = true
isLoading = false
}
}
data class PaginationConfig(
val pageSize: Int = 20,
val prefetchDistance: Int = 5
)
data class PagedResult<T>(
val items: List<T>,
val nextCursor: String?,
val hasMore: Boolean
)
Альтернативы пагинации (для обсуждения):
Offset-based Pagination:
- Проще в реализации
- Подходит для стабильных данных с небольшим объёмом
- Проблемы с дубликатами и пропусками при изменении данных
Key-set Pagination:
- Вариант курсорной пагинации с использованием индексированного поля (например,
created_at) - Требует строгой сортировки по курсорному полю
Интеграция с UI:
Для интеграции пагинации с RecyclerView можно использовать:
- Paging 3 Library (Jetpack Paging) — стандартный подход в Android
- Кастомная реализация через
RecyclerView.OnScrollListener
// Пример интеграции с Paging 3
class DocumentsPagingSource(
private val repository: DocumentsRepository
) : PagingSource<String, Document>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Document> {
return try {
val result = repository.getDocuments(cursor = params.key, limit = params.loadSize)
result.fold(
onSuccess = { pagedResult ->
LoadResult.Page(
data = pagedResult.items,
prevKey = null, // Только прямая пагинация
nextKey = pagedResult.nextCursor
)
},
onFailure = { error ->
LoadResult.Error(error)
}
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
Поток данных (Data Flow):
User Action (scroll to bottom)
│
▼
DocumentsListViewModel
│
▼
PaginationManager.loadNextPage()
│
▼
DocumentsRepository.getDocuments(cursor)
│
├──► RemoteDataSource (API call with cursor)
│
└──► LocalDataSource (cache update)
│
▼
ViewModel updates State with new documents
│
▼
UI renders updated list
Кандидат продемонстрировал отличное понимание принципов проектирования: SRP (выделение пагинатора), обоснованный выбор технологии (курсорная пагинация), правильное разделение на слои (UseCase отдельно от ViewModel и Repository). Это уровень зрелого разработчика, способного принимать архитектурные решения с учётом контекста задачи.
Вопрос 10. Детализируйте архитектуру Flow редактирования документа: компоненты, отправка и получение изменений, стилизация текста.
Таймкод: 00:50:29
Ответ собеседния: Правильный. Кандидат выделил компоненты Flow редактирования: Document Position для работы с сущностями конкретных документов; Editor UI; Use Case с постоянным соединением (WebSocket) для отслеживания обновлений от других пользователей. Для отправки изменений предложил использовать дебаунс с настраиваемым лимитом (например, 10-20 секунд или по количеству символов), чтобы не отправлять каждое нажатие клавиши. Для получения изменений — отдельный Observer, который через репозиторий получает апдейты и передаёт их на UI без полной перерисовки. Для стилизации предложил формат Markdown как простое и масштабируемое решение с поддержкой расширений. Модель стилизации (StyleUpdate) содержит тип стиля (bold, italic и т.д.), позицию начала и конца — отдельная модель от TextUpdate, так как входящие и исходящие параметры отличаются.
Правильный ответ:
Кандидат представил грамотную архитектуру с правильным разделением ответственности. Вот полная детализация всех аспектов Flow редактирования.
1. Presentation Layer
EditorViewModel:
class EditorViewModel(
private val openDocumentUseCase: OpenDocumentUseCase,
private val saveDocumentUseCase: SaveDocumentUseCase,
private val syncDocumentUseCase: SyncDocumentUseCase,
private val applyFormattingUseCase: ApplyFormattingUseCase,
private val shareDocumentUseCase: ShareDocumentUseCase
) : ViewModel() {
private val _state = MutableStateFlow(EditorState())
val state: StateFlow<EditorState> = _state.asStateFlow()
private val _events = MutableSharedFlow<EditorEvent>()
val events: SharedFlow<EditorEvent> = _events.asSharedFlow()
// Текстовый буфер с дебаунсом
private val textChangeBuffer = DebouncedBuffer<TextUpdate>(
delayMs = 500,
maxBufferSize = 50,
onFlush = { updates ->
viewModelScope.launch {
syncDocumentUseCase.sendTextUpdates(updates)
}
}
)
// Слушатель входящих изменений
private val incomingChangesObserver = IncomingChangesObserver { update ->
when (update) {
is IncomingUpdate.TextUpdate -> applyRemoteTextUpdate(update)
is IncomingUpdate.StyleUpdate -> applyRemoteStyleUpdate(update)
is IncomingUpdate.CursorUpdate -> updateRemoteCursor(update)
}
}
fun openDocument(documentId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
openDocumentUseCase.open(documentId)
.onSuccess { document ->
_state.update {
it.copy(
document = document,
isLoading = false,
isConnected = true
)
}
// Подключаемся к WebSocket для real-time обновлений
syncDocumentUseCase.connect(documentId)
incomingChangesObserver.startListening()
}
.onFailure { error ->
_state.update {
it.copy(
isLoading = false,
error = error.message
)
}
}
}
}
fun onTextChanged(update: TextUpdate) {
// Применяем изменение локально
val newState = applyLocalTextUpdate(_state.value, update)
_state.update { newState }
// Буферизуем для отправки на сервер
textChangeBuffer.add(update)
}
fun onStyleApplied(styleUpdate: StyleUpdate) {
// Стилизация применяется сразу (не требует дебаунса)
viewModelScope.launch {
applyFormattingUseCase.apply(styleUpdate)
syncDocumentUseCase.sendStyleUpdate(styleUpdate)
}
}
fun onShareClicked() {
viewModelScope.launch {
_events.emit(EditorEvent.ShowShareDialog)
}
}
}
EditorState:
data class EditorState(
val document: Document? = null,
val content: EditorContent = EditorContent(),
val collaborators: List<CollaboratorCursor> = emptyList(),
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isConnected: Boolean = false,
val hasUnsavedChanges: Boolean = false,
val error: String? = null,
val selection: TextSelection? = null
)
data class EditorContent(
val text: String = "",
val spans: List<StyleSpan> = emptyList()
)
data class StyleSpan(
val style: TextStyle,
val start: Int,
val end: Int
)
enum class TextStyle {
BOLD, ITALIC, UNDERLINE, STRIKETHROUGH,
HEADING_1, HEADING_2, HEADING_3,
BULLET_LIST, NUMBERED_LIST,
LINK, CODE
}
data class CollaboratorCursor(
val userId: String,
val userName: String,
val color: Color,
val position: Int,
val selection: TextSelection? = null
)
2. Модели обновлений
TextUpdate — изменение текста:
data class TextUpdate(
val type: TextUpdateType,
val position: Int,
val content: String? = null, // Для INSERT — вставляемый текст
val length: Int = 0, // Для DELETE — длина удаляемого фрагмента
val revision: Long, // Версия документа для разрешения конфликтов
val timestamp: Long = System.currentTimeMillis()
)
enum class TextUpdateType {
INSERT, DELETE, REPLACE
}
StyleUpdate — изменение стилизации:
data class StyleUpdate(
val style: TextStyle,
val start: Int,
val end: Int,
val isApply: Boolean = true, // true = применить, false = снять
val revision: Long,
val timestamp: Long = System.currentTimeMillis()
)
IncomingUpdate — входящие изменения от других пользователей:
sealed class IncomingUpdate {
data class TextUpdate(
val update: TextUpdate,
val userId: String
) : IncomingUpdate()
data class StyleUpdate(
val update: StyleUpdate,
val userId: String
) : IncomingUpdate()
data class CursorUpdate(
val userId: String,
val position: Int,
val selection: TextSelection?
) : IncomingUpdate()
}
3. Дебаунс и буферизация
DebouncedBuffer:
class DebouncedBuffer<T>(
private val delayMs: Long,
private val maxBufferSize: Int,
private val onFlush: suspend (List<T>) -> Unit
) {
private val buffer = mutableListOf<T>()
private var flushJob: Job? = null
fun add(item: JobScope, T) {
synchronized(buffer) {
buffer.add(item)
if (buffer.size >= maxBufferSize) {
flush(scope)
} else {
scheduleFlush(scope)
}
}
}
private fun scheduleFlush(scope: CoroutineScope) {
flushJob?.cancel()
flushJob = scope.launch {
delay(delayMs)
flush(this)
}
}
private fun flush(scope: CoroutineScope) {
synchronized(buffer) {
if (buffer.isNotEmpty()) {
val itemsToSend = buffer.toList()
buffer.clear()
flushJob?.cancel()
scope.launch {
onFlush(itemsToSend)
}
}
}
}
}
Стратегии дебаунса:
Кандидат правильно предложил дебаунс. Вот варианты стратегий:
- Time-based debounce (500ms–2s): отправка после паузы в наборе текста. Хорошо для обычного набора, но может задерживать сохранение
- Size-based debounce (10–50 изменений): отправка при накоплении определённого количества изменений. Хорошо для быстрого набора
- Hybrid approach (комбинация): отправка при достижении лимита по времени ИЛИ по размеру — наиболее гибкий подход
- Immediate для критичных операций: немедленная отправка при потере фокуса, закрытии экрана, при переключении на другое приложение
4. Real-time синхронизация
WebSocketService:
class WebSocketService(
private val client: OkHttpClient,
private val json: Json
) {
private var webSocket: WebSocket? = null
private val _incomingMessages = MutableSharedFlow<ServerMessage>()
val incomingMessages: SharedFlow<ServerMessage> = _incomingMessages.asSharedFlow()
fun connect(documentId: String) {
val request = Request.Builder()
.url("wss://api.example.com/documents/$documentId/ws")
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
val message = json.decodeFromString<ServerMessage>(text)
_incomingMessages.tryEmit(message)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// Обработка ошибок и переподключение
handleConnectionFailure(t)
}
})
}
fun send(message: ClientMessage) {
val jsonString = json.encodeToString(message)
webSocket?.send(jsonString)
}
fun disconnect() {
webSocket?.close(1000, "User closed document")
webSocket = null
}
}
sealed class ServerMessage {
data class DocumentUpdate(val update: IncomingUpdate) : ServerMessage()
data class UserJoined(val collaborator: CollaboratorCursor) : ServerMessage()
data class UserLeft(val userId: String) : ServerMessage()
data class Acknowledged(val revision: Long) : ServerMessage()
data class Error(val code: Int, val message: String) : ServerMessage()
}
sealed class ClientMessage {
data class TextChange(val update: TextUpdate) : ClientMessage()
data class StyleChange(val update: StyleUpdate) : ClientMessage()
data class CursorMove(val position: Int, val selection: TextSelection?) : ClientMessage()
}
5. Стилизация текста
Кандидат предложил Markdown — это один из подходов. Вот сравнение альтернатив:
Вариант A: Markdown (предложенный кандидатом)
Преимущества:
- Простота реализации и парсинга
- Человекочитаемый формат
- Лёгкая сериализация и десериализация
- Поддержка расширений (tables, code blocks)
Недостатки:
- Ограниченная модель стилизации (нет произвольного цвета текста, размера шрифта)
- Сложности с вложенными стилями (bold + italic + underline)
- Нет позиционной информации — только текстовые маркеры
Вариант B: Rich Text Document Model (рекомендуемый)
Более гибкий подход с явной моделью стилей:
data class RichTextDocument(
val text: String,
val spans: List<StyleSpan>
)
data class StyleSpan(
val attributes: Set<TextAttribute>,
val start: Int,
end: Int
)
sealed class TextAttribute {
object Bold : TextAttribute()
object Italic : TextAttribute()
object Underline : TextAttribute()
data class FontSize(val size: Int) : TextAttribute()
data class Color(val value: Long) : TextAttribute()
data class BackgroundColor(val value: Long) : TextAttribute()
data class Heading(val level: Int) : TextAttribute()
data class Link(val url: String) : TextAttribute()
}
Преимущества:
- Полный контроль над моделью стилей
- Простое применение/снятие стилей по диапазону
- Легко сериализуется в JSON для передачи по сети
- Поддержка произвольных вложенных стилей
Недостатки:
- Более сложная реализация
- Больший объём данных при передаче
Вариант C: Operational Transformation (OT) / CRDT
Для production-решений с коллаборативным редактированием рекомендуется использовать алгоритмы разрешения конфликтов:
- Operational Transformation (OT): используется в Google Docs. Каждое изменение представляется как операция, которая трансформируется с учётом параллельных изменений
- CRDT (Conflict-free Replicated Data Types): используется в Figma, Notion. Каждый символ имеет уникальный идентификатор, что позволяет автоматически разрешать конфликты без центрального сервера
6. Применение изменений без полной перерисовки
Кандидат правильно отметил важность инкрементального обновления UI. Вот подход:
IncrementalUpdateStrategy:
class IncrementalUpdateStrategy {
fun applyTextUpdate(
currentState: EditorContent,
update: TextUpdate
): EditorContent {
return when (update.type) {
TextUpdateType.INSERT -> {
val newText = currentState.text.substring(0, update.position) +
update.content +
currentState.text.substring(update.position)
// Корректируем позиции span'ов после вставки
val newSpans = adjustSpansAfterInsert(
currentState.spans,
update.position,
update.content.length
)
currentState.copy(text = newText, spans = newSpans)
}
TextUpdateType.DELETE -> {
val newText = currentState.text.substring(0, update.position) +
currentState.text.substring(update.position + update.length)
// Корректируем позиции span'ов после удаления
val newSpans = adjustSpansAfterDelete(
currentState.spans,
update.position,
update.length
)
currentState.copy(text = newText, spans = newSpans)
}
TextUpdateType.REPLACE -> {
// Комбинация DELETE + INSERT
applyTextUpdate(
currentState,
update.copy(type = TextUpdateType.DELETE)
).let {
applyTextUpdate(
it,
update.copy(type = TextUpdateType.INSERT)
)
}
}
}
}
fun applyStyleUpdate(
currentState: EditorContent,
update: StyleUpdate
): EditorContent {
val newSpans = if (update.isApply) {
// Добавляем или объединяем span
mergeSpan(currentState.spans, StyleSpan(update.style, update.start, update.end))
} else {
// Удаляем стиль из span'ов
removeStyleFromSpans(currentState.spans, update.style, update.start, update.end)
}
return currentState.copy(spans = newSpans)
}
private fun adjustSpansAfterInsert(
spans: List<StyleSpan>,
insertPosition: Int,
insertLength: Int
): List<StyleSpan> {
return spans.map { span ->
when {
span.start >= insertPosition -> {
// Span полностью после вставки — сдвигаем
span.copy(
start = span.start + insertLength,
end = span.end + insertLength
)
}
span.end > insertPosition -> {
// Span перекрывает позицию вставки — расширяем
span.copy(end = span.end + insertLength)
}
else -> span // Span полностью до вставки — не меняем
}
}
}
}
7. Оффлайн-режим и синхронизация
OfflineFirstStrategy:
class OfflineFirstStrategy(
private val localDataSource: DocumentsLocalDataSource,
private val pendingChangesDao: PendingChangesDao,
private val syncManager: SyncManager
) {
suspend fun applyLocalChange(update: TextUpdate): Result<Unit> {
// 1. Применяем изменение локально
localDataSource.applyUpdate(update)
// 2. Сохраняем в очередь для синхронизации
pendingChangesDao.save(
PendingChange(
documentId = update.documentId,
update = update,
timestamp = System.currentTimeMillis(),
synced = false
)
)
// 3. Пытаемся отправить если онлайн
if (syncManager.isOnline()) {
syncManager.syncPendingChanges(update.documentId)
}
return Result.success(Unit)
}
suspend fun syncWhenOnline(documentId: String) {
val pendingChanges = pendingChangesDao.getPendingChanges(documentId)
// Отправляем пачкой
syncManager.sendBatch(pendingChanges)
// Получаем серверные изменения
val serverChanges = syncManager.fetchServerChanges(
documentId,
lastKnownRevision = localDataSource.getLastRevision(documentId)
)
// Применяем серверные изменения
serverChanges.forEach { update ->
localDataSource.applyUpdate(update)
}
// Очищаем отправленные изменения
pendingChangesDao.markAsSynced(pendingChanges.map { it.id })
}
}
Архитектурная схема потока изменений:
┌─────────────────────────────────────────────────────────────┐
│ Editor Screen │
│ ┌──────────┐ ┌───────────────┐ ┌──────────────────────┐ │
│ │ Toolbar │ │ RichTextEditor│ │ CollaboratorsPanel │ │
│ └──────────┘ └───────┬───────┘ └──────────────────────┘ │
│ │ │
│ ┌─────────────────────▼──────────────────────────────────┐ │
│ │ EditorViewModel │ │
│ │ ┌─────────────┐ ┌────────────┐ ┌────────────────┐ │ │
│ │ │ TextBuffer │ │ StyleMgr │ │ CursorTracker │ │ │
│ │ │ (debounce) │ │ (immediate)│ │ (throttle) │ │ │
│ │ └──────┬──────┘ └─────┬──────┘ └───────┬────────┘ │ │
│ └─────────┼───────────────┼─────────────────┼────────────┘ │
└────────────┼───────────────┼─────────────────┼──────────────┘
│ │ │
┌────────────▼───────────────▼─────────────────▼──────────────┐
│ Domain Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ SaveDocument │ │ApplyFormatting│ │ SyncDocument │ │
│ │ UseCase │ │ UseCase │ │ UseCase │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬──────────┘ │
└─────────┼─────────────────┼───────────────────┼──────────────┘
│ │ │
┌─────────▼─────────────────▼───────────────────▼──────────────┐
│ Data Layer │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DocumentsRepository │ │
│ └──────────┬──────────────────────────────┬───────────────┘ │
│ │ │ │
│ ┌──────────▼──────────┐ ┌───────────▼────────────────┐ │
│ │ RemoteDataSource │ │ LocalDataSource │ │
│ │ ┌───────────────┐ │ │ ┌──────────────────────┐ │ │
│ │ │ ApiService │ │ │ │ DocumentsDao │ │ │
│ │ │ WebSocketSvc │ │ │ │ PendingChangesDao │ │ │
│ │ └───────────────┘ │ │ └──────────────────────┘ │ │
│ └──────────┬──────────┘ └────────────────────────────┘ │
└─────────────┼──────────────────────────────────────────────────┘
│
┌─────────────▼──────────────────────────────────────────────────┐
│ Backend │
│ ┌────────────┐ ┌────────────────┐ ┌──────────────────────┐ │
│ │API Gateway │ │Collaboration │ │ Documents DB │ │
│ │ │ │Service (OT) │ │ │ │
│ └────────────┘ └────────────────┘ └──────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
Кандидат продемонстрировал глубокое понимание архитектуры редактора: правильное разделение TextUpdate и StyleUpdate, обоснованное использование дебаунса, инкрементальное обновление UI, выделение отдельных моделей для разных типов изменений. Это уровень разработчика с реальным опытом проектирования сложных UI-интенсивных систем.
Вопрос 11. Какой паттерн представления (MVP/MVVM) вы выберете для данного приложения и почему? Опишите структуру ViewModel и State.
Таймкод: 01:05:09
Ответ собеседника: Правильный. Кандидат выбрал MVVM по нескольким причинам: поддержка и форсинг со стороны Google, наличие инфраструктурной поддержки в SDK (ViewModel с переживанием убийства процессов и прочими обвязками), большое количество примеров. ViewModel менее избыточна по сравнению с MVP (нет необходимости создавать интерфейсы для View), ViewModel не знает о своих клиентах (обратная связь через подписку на State/Flow). Структура: UI Controller (Activity/Fragment) отправляет команды (Events) во ViewModel через единственный публичный метод; ViewModel обрабатывает события и меняет состояние (State). State — один sealed class с состояниями: Loading, Content, Error — что упрощает тестирование, понимание текущего состояния и логирование, а также облегчает миграцию на декларативные фреймворки (Jetpack Compose/SwiftUI).
Правильный ответ:
Кандидат дал отличный ответ с обоснованным выбором и правильной структурой. Вот полное сравнение паттернов и детализация реализации.
Сравнение паттернов представления
MVP (Model-View-Presenter):
Преимущества:
- Явный контракт через интерфейс View — легко мокать в тестах
- Presenter не зависит от Android SDK
- Полный контроль над жизненным циклом
Недостатки:
- Вербозность: нужно создавать интерфейс для каждого View
- Ручное управление подписками и отписками
- Нет встроенной поддержки выживания при повороте экрана
- Тесная связь через интерфейс: Presenter знает о конкретных методах View
MVVM (Model-View-ViewModel):
Преимущества (указанные кандидатом):
- Google support: официальная рекомендация, инфраструктурная поддержка в Android SDK
- ViewModel survives process death: автоматическое переживание поворотов и восстановление после смерти процесса
- Less boilerplate: нет необходимости создавать интерфейсы для View
- Reactive binding: View подписывается на State/Flow, ViewModel не знает о подписчиках
- Compose-ready: естественно ложится на декларативный UI
Недостатки:
- ViewModel может разрастаться (нужна дисциплина или MVI-подход)
- Сложнее отлаживать потоки данных при большом количестве состояний
- Risk of "God ViewModel" без чёткого разделения ответственности
MVI (Model-View-Intent):
Более строгая вариация MVVM:
- Единый State для всего экрана
- Intent/Event — все действия пользователя типизированы
- Предсказуемый цикл: Intent → ViewModel → State → View
- Легче тестировать и воспроизводить сценарии
Рекомендация для данного проекта:
Для текстового редактора с коллаборативным редактированием оптимален MVI-подход на базе MVVM, потому что:
- Сложная логика состояний (загрузка, редактирование, синхронизация, ошибки)
- Необходимость воспроизводимости состояний для отладки
- Множество источников событий (пользователь, WebSocket, фоновые задачи)
Структура State и Event
Подход 1: Sealed Class State (предложенный кандидатом):
sealed class EditorUiState {
object Loading : EditorUiState()
data class Content(
val document: DocumentUi,
val isSaving: Boolean,
val isConnected: Boolean,
val collaborators: List<CollaboratorUi>
) : EditorUiState()
data class Error(val message: String) : EditorUiState()
}
Преимущества: простота, все состояния в одном месте. Недостатки: при большом количестве полей становится громоздким, сложно обновлять отдельные поля.
Подход 2: Single Data Class State (рекомендуемый для сложных экранов):
data class EditorUiState(
val isLoading: Boolean = false,
val document: DocumentUi? = null,
val content: EditorContentUi = EditorContentUi(),
val isSaving: Boolean = false,
val isConnected: Boolean = false,
val collaborators: List<CollaboratorUi> = emptyList(),
val error: String? = null,
val selection: TextSelectionUi? = null
) {
val hasContent: Boolean get() = document != null
val hasError: Boolean get() = error != null
}
Преимущества: легко обновлять отдельные поля через copy(), все состояния видны в одном месте.
Типизированные события (Events):
sealed class EditorEvent {
// Жизненный цикл
data class OpenDocument(val documentId: String) : EditorEvent()
object CloseDocument : EditorEvent()
// Текстовые операции
data class TextInserted(val position: Int, val text: String) : EditorEvent()
data class TextDeleted(val position: Int, val length: Int) : EditorEvent()
data class SelectionChanged(val start: Int, val end: Int) : EditorEvent()
// Форматирование
data class ApplyStyle(val style: TextStyle, val start: Int, val end: Int) : EditorEvent()
data class RemoveStyle(val style: TextStyle, val start: Int, val end: Int) : EditorEvent()
// Действия
object SaveDocument : EditorEvent()
object ShareDocument : EditorEvent()
object CreateDocument : EditorEvent()
// Системные
object OnResume : EditorEvent()
object OnPause : EditorEvent()
data class NetworkStateChanged(val isConnected: Boolean) : EditorEvent()
}
Реализация ViewModel с MVI-подходом:
class EditorViewModel @Inject constructor(
private val openDocumentUseCase: OpenDocumentUseCase,
private val saveDocumentUseCase: SaveDocumentUseCase,
private val syncDocumentUseCase: SyncDocumentUseCase,
private val applyFormattingUseCase: ApplyFormattingUseCase
) : ViewModel() {
private val _state = MutableStateFlow(EditorUiState())
val state: StateFlow<EditorUiState> = _state.asStateFlow()
// Одноразовые события (навигация, снэкбары)
private val _events = MutableSharedFlow<EditorSingleEvent>()
val events: SharedFlow<EditorSingleEvent> = _events.asSharedFlow()
// Обработчик всех событий (single entry point)
fun onEvent(event: EditorEvent) {
when (event) {
is EditorEvent.OpenDocument -> handleOpenDocument(event.documentId)
is EditorEvent.CloseDocument -> handleCloseDocument()
is EditorEvent.TextInserted -> handleTextInserted(event.position, event.text)
is EditorEvent.TextDeleted -> handleTextDeleted(event.position, event.length)
is EditorEvent.SelectionChanged -> handleSelectionChanged(event.start, event.end)
is EditorEvent.ApplyStyle -> handleApplyStyle(event.style, event.start, event.end)
is EditorEvent.RemoveStyle -> handleRemoveStyle(event.style, event.start, event.end)
is EditorEvent.SaveDocument -> handleSaveDocument()
is EditorEvent.ShareDocument -> handleShareDocument()
is EditorEvent.CreateDocument -> handleCreateDocument()
is EditorEvent.OnResume -> handleResume()
is EditorEvent.OnPause -> handlePause()
is EditorEvent.NetworkStateChanged -> handleNetworkStateChanged(event.isConnected)
}
}
private fun handleOpenDocument(documentId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
openDocumentUseCase(documentId)
.onSuccess { document ->
_state.update {
it.copy(
isLoading = false,
document = document.toUi(),
isConnected = true
)
}
syncDocumentUseCase.connect(documentId)
}
.onFailure { error ->
_state.update {
it.copy(
isLoading = false,
error = error.message
)
}
}
}
}
private fun handleTextInserted(position: Int, text: String) {
viewModelScope.launch {
val currentState = _state.value
// Применяем изменение локально
val newContent = currentState.content.insertText(position, text)
_state.update {
it.copy(
content = newContent,
isSaving = true
)
}
// Отправляем на сервер через дебаунс
syncDocumentUseCase.sendTextUpdate(
TextUpdate(
type = TextUpdateType.INSERT,
position = position,
content = text,
revision = currentState.document?.revision ?: 0
)
)
}
}
private fun handleApplyStyle(style: TextStyle, start: Int, end: Int) {
viewModelScope.launch {
val newContent = _state.value.content.applyStyle(style, start, end)
_state.update { it.copy(content = newContent) }
syncDocumentUseCase.sendStyleUpdate(
StyleUpdate(
style = style,
start = start,
end = end,
isApply = true,
revision = _state.value.document?.revision ?: 0
)
)
}
}
}
// Одноразовые события (не сохраняются в State)
sealed class EditorSingleEvent {
data class NavigateToDocument(val documentId: String) : EditorSingleEvent()
data class ShowSnackbar(val message: String) : EditorSingleEvent()
object NavigateBack : EditorSingleEvent()
data class ShowShareDialog(val documentId: String) : EditorSingleEvent()
}
Интеграция с UI (Fragment/Activity):
class EditorFragment : Fragment() {
private val viewModel: EditorViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Подписка на State
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
renderState(state)
}
}
}
// Подписка на одноразовые события
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
handleSingleEvent(event)
}
}
}
// Привязка UI к ViewModel
binding.editorView.onTextChanged = { position, text ->
viewModel.onEvent(EditorEvent.TextInserted(position, text))
}
binding.editorView.onSelectionChanged = { start, end ->
viewModel.onEvent(EditorEvent.SelectionChanged(start, end))
}
binding.toolbar.onStyleClicked = { style ->
val selection = viewModel.state.value.selection
if (selection != null) {
viewModel.onEvent(EditorEvent.ApplyStyle(style, selection.start, selection.end))
}
}
binding.shareButton.setOnClickListener {
viewModel.onEvent(EditorEvent.ShareDocument)
}
}
private fun renderState(state: EditorUiState) {
binding.progressBar.isVisible = state.isLoading
binding.editorView.isVisible = state.hasContent
binding.errorView.isVisible = state.hasError
state.document?.let { doc ->
binding.title.text = doc.title
binding.editorView.setContent(state.content)
binding.editorView.setCollaborators(state.collaborators)
}
state.error?.let { error ->
binding.errorText.text = error
}
binding.savingIndicator.isVisible = state.isSaving
binding.connectionStatus.isVisible = !state.isConnected
}
private fun handleSingleEvent(event: EditorSingleEvent) {
when (event) {
is EditorSingleEvent.NavigateToDocument -> {
findNavController().navigate(
EditorFragmentDirections.toDocument(event.documentId)
)
}
is EditorSingleEvent.ShowSnackbar -> {
Snackbar.make(requireView(), event.message, Snackbar.LENGTH_SHORT).show()
}
is EditorSingleEvent.NavigateBack -> {
findNavController().popBackStack()
}
is EditorSingleEvent.ShowShareDialog -> {
ShareDialog.newInstance(event.documentId).show(childFragmentManager, "share")
}
}
}
}
Миграция на Jetpack Compose:
MVI-подход с StateFlow естественно ложится на Compose:
@Composable
fun EditorScreen(
viewModel: EditorViewModel = hiltViewModel(),
documentId: String
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(documentId) {
viewModel.onEvent(EditorEvent.OpenDocument(documentId))
}
// Одноразовые события через SnackbarHostState
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is EditorSingleEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
}
// ... обработка других событий
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { EditorTopBar(state.document?.title ?: "") }
) { padding ->
when {
state.isLoading -> LoadingIndicator(modifier = Modifier.padding(padding))
state.hasError -> ErrorView(
error = state.error ?: "",
modifier = Modifier.padding(padding)
)
state.hasContent -> EditorContent(
content = state.content,
collaborators = state.collaborators,
onTextChanged = { pos, text ->
viewModel.onEvent(EditorEvent.TextInserted(pos, text))
},
onStyleApplied = { style, start, end ->
viewModel.onEvent(EditorEvent.ApplyStyle(style, start, end))
},
modifier = Modifier.padding(padding)
)
}
}
}
Итого по выбору паттерна:
Кандидат абсолютно правильно выбрал MVVM с элементами MVI. Ключевые аргументы:
- Google поддержка и инфраструктурная интеграция
- Меньше boilerplate по сравнению с MVP
- Реактивная модель через State/Flow
- Готовность к миграции на Compose
- Разделение на State (состояние) и Event (действия) делает код предсказуемым и тестируемым
Вопрос 12. Как будет организовано отображение текста и стилизации на UI? Как применяются входящие обновления без полной перерисовки интерфейса?
Таймкод: 01:12:00
Ответ собеседника: Правильный. Кандидат предложил использовать механизм Diff для обновления UI. Из domain-слоя приходит модель Update, которая может включать изменение текста, изменение стиля или оба одновременно. Поскольку несколько пользователей могут редактировать одновременно, приходит не один Update, а набор (список) обновлений, которые нужно применить одновременно. Для этого вводится сущность Diff, которая содержит список отдельных изменений (DiffItems). Каждый DiffItem — это либо изменение текста, либо изменение стиля, либо и то и другое. Этот Diff приходит в UI-слой, где применяется к текущему отображению без полной перерисовки всего интерфейса, что снижает потребление ресурсов и предотвращает мерцание.
Правильный ответ:
Кандидат предложил грамотный подход с Diff-механизмом. Вот полная архитектура отображения текста и инкрементального обновления UI.
1. Модель данных для отображения
EditorContent — полная модель содержимого:
data class EditorContent(
val plainText: String,
val spans: List<StyledSpan>,
val cursors: List<CursorPosition>
)
data class StyledSpan(
val start: Int,
val end: Int,
val styles: Set<TextStyle>,
val attributes: Map<TextAttribute, String> = emptyMap()
)
data class CursorPosition(
val userId: String,
val userName: String,
val position: Int,
val selection: TextSelection? = null,
val color: Color
)
sealed class TextStyle {
object Bold : TextStyle()
object Italic : TextStyle()
object Underline : TextStyle()
object Strikethrough : TextStyle()
data class Heading(val level: Int) : TextStyle()
object BulletList : TextStyle()
object NumberedList : TextStyle()
object Code : TextStyle()
data class Link(val url: String) : TextStyle()
}
sealed class TextAttribute {
data class FontSize(val size: Int) : TextAttribute()
data class FontColor(val value: Long) : TextAttribute()
data class BackgroundColor(val value: Long) : TextAttribute()
data class FontFamily(val name: String) : TextAttribute()
}
2. Модель обновлений (Diff)
Кандидат правильно выделил концепцию Diff. Вот детальная реализация:
data class EditorDiff(
val textUpdates: List<TextDiffUpdate> = emptyList(),
val styleUpdates: List<StyleDiffUpdate> = emptyList(),
val cursorUpdates: List<CursorDiffUpdate> = emptyList(),
val metadataUpdates: List<MetadataDiffUpdate> = emptyList()
)
sealed class TextDiffUpdate {
data class Insert(
val position: Int,
val text: String
) : TextDiffUpdate()
data class Delete(
val position: Int,
val length: Int
) : TextDiffUpdate()
data class Replace(
val position: Int,
val oldLength: Int,
val newText: String
) : TextDiffUpdate()
}
sealed class StyleDiffUpdate {
data class Apply(
val start: Int,
val end: Int,
val style: TextStyle,
val attributes: Map<TextAttribute, String> = emptyMap()
) : StyleDiffUpdate()
data class Remove(
val start: Int,
val end: Int,
val style: TextStyle
) : StyleDiffUpdate()
}
sealed class CursorDiffUpdate {
data class UpdatePosition(
val userId: String,
val newPosition: Int,
val newSelection: TextSelection?
) : CursorDiffUpdate()
data class AddCursor(
val cursor: CursorPosition
) : CursorDiffUpdate()
data class RemoveCursor(
val userId: String
) : CursorDiffUpdate()
}
sealed class MetadataDiffUpdate {
data class UpdateTitle(val newTitle: String) : MetadataDiffUpdate()
data class UpdateLastModified(val timestamp: Long) : MetadataDiffUpdate()
}
3. DiffApplicator — применение изменений
Базовый интерфейс:
interface DiffApplicator {
fun applyTextUpdate(content: EditorContent, update: TextDiffUpdate): EditorContent
fun applyStyleUpdate(content: EditorContent, update: StyleDiffUpdate): EditorContent
fun applyCursorUpdate(content: EditorContent, update: CursorDiffUpdate): EditorContent
fun applyDiff(content: EditorContent, diff: EditorDiff): EditorContent
}
Реализация:
class EditorDiffApplicator : DiffApplicator {
override fun applyDiff(content: EditorContent, diff: EditorDiff): EditorContent {
var result = content
// Применяем текстовые изменения (сортируем по позиции в обратном порядке,
// чтобы позиции не съезжали при вставках/удалениях)
val sortedTextUpdates = diff.textUpdates.sortedByDescending {
when (it) {
is TextDiffUpdate.Insert -> it.position
is TextDiffUpdate.Delete -> it.position
is TextDiffUpdate.Replace -> it.position
}
}
for (update in sortedTextUpdates) {
result = applyTextUpdate(result, update)
}
// Применяем стилевые изменения
for (update in diff.styleUpdates) {
result = applyStyleUpdate(result, update)
}
// Применяем изменения курсоров
for (update in diff.cursorUpdates) {
result = applyCursorUpdate(result, update)
}
return result
}
override fun applyTextUpdate(content: EditorContent, update: TextDiffUpdate): EditorContent {
return when (update) {
is TextDiffUpdate.Insert -> applyInsert(content, update)
is TextDiffUpdate.Delete -> applyDelete(content, update)
is TextDiffUpdate.Replace -> applyReplace(content, update)
}
}
private fun applyInsert(content: EditorContent, update: TextDiffUpdate.Insert): EditorContent {
val newText = content.plainText.substring(0, update.position) +
update.text +
content.plainText.substring(update.position)
// Корректируем позиции span'ов
val newSpans = content.spans.map { span ->
adjustSpanForInsert(span, update.position, update.text.length)
}
// Корректируем позиции курсоров
val newCursors = content.cursors.map { cursor ->
adjustCursorForInsert(cursor, update.position, update.text.length)
}
return content.copy(
plainText = newText,
spans = newSpans,
cursors = newCursors
)
}
private fun applyDelete(content: EditorContent, update: TextDiffUpdate.Delete): EditorContent {
val newText = content.plainText.substring(0, update.position) +
content.plainText.substring(update.position + update.length)
val newSpans = content.spans.map { span ->
adjustSpanForDelete(span, update.position, update.length)
}
val newCursors = content.cursors.map { cursor ->
adjustCursorForDelete(cursor, update.position, update.length)
}
return content.copy(
plainText = newText,
spans = newSpans,
cursors = newCursors
)
}
private fun applyReplace(content: EditorContent, update: TextDiffUpdate.Replace): EditorContent {
// Замена = удаление + вставка
val afterDelete = applyDelete(
content,
TextDiffUpdate.Delete(update.position, update.oldLength)
)
return applyInsert(
afterDelete,
TextDiffUpdate.Insert(update.position, update.newText)
)
}
private fun adjustSpanForInsert(
span: StyledSpan,
insertPos: Int,
insertLen: Int
): StyledSpan {
return when {
// Span полностью после вставки
span.start >= insertPos -> {
span.copy(
start = span.start + insertLen,
end = span.end + insertLen
)
}
// Вставка внутри span
span.end > insertPos -> {
span.copy(end = span.end + insertLen)
}
// Span полностью до вставки — не меняем
else -> span
}
}
private fun adjustSpanForDelete(
span: StyledSpan,
deletePos: Int,
deleteLen: Int
): StyledSpan {
val deleteEnd = deletePos + deleteLen
return when {
// Span полностью после удаления
span.start >= deleteEnd -> {
span.copy(
start = span.start - deleteLen,
end = span.end - deleteLen
)
}
// Span полностью внутри удаляемого диапазона — удаляем span
span.start >= deletePos && span.end <= deleteEnd -> {
span.copy(start = deletePos, end = deletePos) // Пустой span
}
// Удаление перекрывает начало span
span.start < deletePos && span.end > deletePos && span.end <= deleteEnd -> {
span.copy(end = deletePos)
}
// Удаление внутри span
span.start < deletePos && span.end > deleteEnd -> {
span.copy(end = span.end - deleteLen)
}
// Удаление перекрывает конец span
span.start >= deletePos && span.end > deleteEnd -> {
span.copy(
start = deletePos,
end = deletePos + (span.end - deleteEnd)
)
}
// Span полностью до удаления — не меняем
else -> span
}
}
private fun adjustCursorForInsert(
cursor: CursorPosition,
insertPos: Int,
insertLen: Int
): CursorPosition {
return if (cursor.position >= insertPos) {
cursor.copy(position = cursor.position + insertLen)
} else {
cursor
}
}
private fun adjustCursorForDelete(
cursor: CursorPosition,
deletePos: Int,
deleteLen: Int
): CursorPosition {
val deleteEnd = deletePos + deleteLen
return when {
cursor.position >= deleteEnd -> {
cursor.copy(position = cursor.position - deleteLen)
}
cursor.position >= deletePos -> {
cursor.copy(position = deletePos)
}
else -> cursor
}
}
override fun applyStyleUpdate(content: EditorContent, update: StyleDiffUpdate): EditorContent {
return when (update) {
is StyleDiffUpdate.Apply -> {
val newSpans = mergeSpan(content.spans, StyledSpan(
start = update.start,
end = update.end,
styles = setOf(update.style),
attributes = update.attributes
))
content.copy(spans = newSpans)
}
is StyleDiffUpdate.Remove -> {
val newSpans = content.spans.map { span ->
if (span.start == update.start && span.end == update.end) {
span.copy(styles = span.styles - update.style)
} else {
span
}
}
content.copy(spans = newSpans)
}
}
}
override fun applyCursorUpdate(content: EditorContent, update: CursorDiffUpdate): EditorContent {
return when (update) {
is CursorDiffUpdate.UpdatePosition -> {
val newCursors = content.cursors.map { cursor ->
if (cursor.userId == update.userId) {
cursor.copy(
position = update.newPosition,
selection = update.newSelection
)
} else {
cursor
}
}
content.copy(cursors = newCursors)
}
is CursorDiffUpdate.AddCursor -> {
content.copy(cursors = content.cursors + update.cursor)
}
is CursorDiffUpdate.RemoveCursor -> {
content.copy(cursors = content.cursors.filter { it.userId != update.userId })
}
}
}
private fun mergeSpan(existingSpans: List<StyledSpan>, newSpan: StyledSpan): List<StyledSpan> {
// Логика объединения span'ов с разрешением конфликтов
val result = mutableListOf<StyledSpan>()
for (span in existingSpans) {
if (span.end <= newSpan.start || span.start >= newSpan.end) {
// Нет пересечения
result.add(span)
} else {
// Есть пересечение — разделяем span
if (span.start < newSpan.start) {
result.add(span.copy(end = newSpan.start))
}
val overlapStart = maxOf(span.start, newSpan.start)
val overlapEnd = minOf(span.end, newSpan.end)
result.add(StyledSpan(
start = overlapStart,
end = overlapEnd,
styles = span.styles + newSpan.styles,
attributes = span.attributes + newSpan.attributes
))
if (span.end > newSpan.end) {
result.add(span.copy(start = newSpan.end))
}
}
}
// Добавляем новый span, если он не полностью перекрыт существующими
val isFullyCovered = result.any {
it.start <= newSpan.start && it.end >= newSpan.end
}
if (!isFullyCovered) {
result.add(newSpan)
}
return result.sortedBy { it.start }
}
}
4. UI-слой: TextView с инкрементальным обновлением
Кастомный EditorView:
class EditorView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val textView: AppCompatTextView
private val cursorOverlay: CursorOverlay
private val applicator = EditorDiffApplicator()
private var currentContent: EditorContent = EditorContent()
var onTextChanged: ((Int, String) -> Unit)? = null
var onSelectionChanged: ((Int, Int) -> Unit)? = null
init {
textView = AppCompatTextView(context).apply {
setupTextWatcher()
}
cursorOverlay = CursorOverlay(context)
addView(textView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(cursorOverlay, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
fun applyDiff(diff: EditorDiff) {
val oldContent = currentContent
val newContent = applicator.applyDiff(oldContent, diff)
// Вычисляем реальные изменения для минимального обновления UI
val textChanges = calculateTextChanges(oldContent.plainText, newContent.plainText)
val spanChanges = calculateSpanChanges(oldContent.spans, newContent.spans)
// Применяем изменения инкрементально
applyTextChangesIncremental(textChanges)
applySpanChangesIncremental(spanChanges)
updateCursorOverlay(newContent.cursors)
currentContent = newContent
}
fun setContent(content: EditorContent) {
currentContent = content
textView.text = buildSpannedString(content)
updateCursorOverlay(content.cursors)
}
private fun buildSpannedString(content: EditorContent): SpannableStringBuilder {
val builder = SpannableStringBuilder(content.plainText)
for (span in content.spans) {
for (style in span.styles) {
val spanObject = when (style) {
is TextStyle.Bold -> StyleSpan(Typeface.BOLD)
is TextStyle.Italic -> StyleSpan(Typeface.ITALIC)
is TextStyle.Underline -> UnderlineSpan()
is TextStyle.Strikethrough -> StrikethroughSpan()
is TextStyle.Heading -> {
val scale = when (style.level) {
1 -> 1.5f
2 -> 1.3f
3 -> 1.1f
else -> 1.0f
}
RelativeSizeSpan(scale)
}
is TextStyle.Link -> object : ClickableSpan() {
override fun onClick(widget: View) {
// Обработка клика по ссылке
}
}
is TextStyle.Code -> TypefaceSpan("monospace")
else -> continue
}
builder.setSpan(
spanObject,
span.start,
span.end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
return builder
}
private fun calculateTextChanges(oldText: String, newText: String): List<TextChange> {
// Используем Myers diff algorithm или аналог
val changes = mutableListOf<TextChange>()
// Упрощённая реализация: находим первое и последнее отличие
var firstDiff = 0
while (firstDiff < oldText.length && firstDiff < newText.length
&& oldText[firstDiff] == newText[firstDiff]) {
firstDiff++
}
var lastDiffOld = oldText.length - 1
var lastDiffNew = newText.length - 1
while (lastDiffOld >= firstDiff && lastDiffNew >= firstDiff
&& oldText[lastDiffOld] == newText[lastDiffNew]) {
lastDiffOld--
lastDiffNew--
}
if (firstDiff <= lastDiffOld || firstDiff <= lastDiffNew) {
changes.add(TextChange(
start = firstDiff,
oldEnd = lastDiffOld + 1,
newEnd = lastDiffNew + 1,
oldText = oldText.substring(firstDiff, lastDiffOld + 1),
newText = newText.substring(firstDiff, lastDiffNew + 1)
))
}
return changes
}
private fun calculateSpanChanges(
oldSpans: List<StyledSpan>,
newSpans: List<StyledSpan>
): List<SpanChange> {
val changes = mutableListOf<SpanChange>()
// Находим добавленные, удалённые и изменённые span'ы
val oldSpanSet = oldSpans.toSet()
val newSpanSet = newSpans.toSet()
val added = newSpanSet - oldSpanSet
val removed = oldSpanSet - newSpanSet
changes.addAll(added.map { SpanChange.Added(it) })
changes.addAll(removed.map { SpanChange.Removed(it) })
return changes
}
private fun applyTextChangesIncremental(changes: List<TextChange>) {
val editable = textView.text as? Editable ?: return
for (change in changes) {
editable.replace(change.start, change.oldEnd, change.newText)
}
}
private fun applySpanChangesIncremental(changes: List<SpanChange>) {
val spannable = textView.text as? Spannable ?: return
for (change in changes) {
when (change) {
is SpanChange.Added -> {
// Добавляем новый span
val spanObject = createSpanObject(change.span.styles.first())
spannable.setSpan(
spanObject,
change.span.start,
change.span.end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
is SpanChange.Removed -> {
// Удаляем span
val spans = spannable.getSpans(
change.span.start,
change.span.end,
Any::class.java
)
for (span in spans) {
spannable.removeSpan(span)
}
}
}
}
}
private fun updateCursorOverlay(cursors: List<CursorPosition>) {
cursorOverlay.updateCursors(cursors)
}
private fun setupTextWatcher() {
textView.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (before == 0 && count > 0) {
// Вставка текста
onTextChanged?.invoke(start, s?.substring(start, start + count) ?: "")
} else if (before > 0 && count == 0) {
// Удаление текста
onTextChanged?.invoke(start, "")
}
}
override fun afterTextChanged(s: Editable?) {}
})
}
}
data class TextChange(
val start: Int,
val oldEnd: Int,
val newEnd: Int,
val oldText: String,
val newText: String
)
sealed class SpanChange {
data class Added(val span: StyledSpan) : SpanChange()
data class Removed(val span: StyledSpan) : SpanChange()
}
5. Cursor Overlay — отображение курсоров других пользователей:
class CursorOverlay @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val cursorPaint = Paint().apply {
style = Paint.Style.FILL
}
private val selectionPaint = Paint().apply {
style = Paint.Style.FILL
alpha = 80
}
private val cursors = mutableListOf<CursorPosition>()
fun updateCursors(newCursors: List<CursorPosition>) {
cursors.clear()
cursors.addAll(newCursors)
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
for (cursor in cursors) {
// Рисуем курсор
cursorPaint.color = cursor.color.toArgb()
// Вычисляем позицию курсора на экране
val cursorX = calculateCursorX(cursor.position)
val cursorY = calculateCursorY(cursor.position)
canvas.drawRect(
cursorX - 2, cursorY,
cursorX + 2, cursorY + 40,
cursorPaint
)
// Рисуем выделение, если есть
cursor.selection?.let { selection ->
selectionPaint.color = cursor.color.toArgb()
val selectionStartX = calculateCursorX(selection.start)
val selectionEndX = calculateCursorX(selection.end)
val selectionY = calculateCursorY(selection.start)
canvas.drawRect(
selectionStartX, selectionY,
selectionEndX, selectionY + 40,
selectionPaint
)
}
// Рисуем имя пользователя
canvas.drawText(
cursor.userName,
cursorX,
cursorY - 10,
Paint().apply {
color = cursor.color.toArgb()
textSize = 24f
}
)
}
}
private fun calculateCursorX(position: Int): Float {
// Вычисляем X-координату на основе позиции в тексте
return position * 10f // Упрощённо
}
private fun calculateCursorY(position: Int): Float {
// Вычисляем Y-координату на основе позиции в тексте
return (position / 50) * 40f // Упрощённо
}
}
6. Интеграция с ViewModel:
class EditorViewModel @Inject constructor(
private val syncDocumentUseCase: SyncDocumentUseCase
) : ViewModel() {
private val _content = MutableStateFlow(EditorContent())
val content: StateFlow<EditorContent> = _content.asStateFlow()
init {
// Подписка на входящие обновления
viewModelScope.launch {
syncDocumentUseCase.incomingDiffs.collect { diff ->
val applicator = EditorDiffApplicator()
val newContent = applicator.applyDiff(_content.value, diff)
_content.value = newContent
}
}
}
}
Архитектурная схема потока обновлений:
┌─────────────────────────────────────────────────────────────────┐
│ Incoming Updates │
│ (WebSocket, Local Changes, Server Sync) │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ EditorViewModel │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ incomingDiffs.collect { diff -> ││
│ │ val newContent = applicator.applyDiff(content, diff) ││
│ │ _content.value = newContent ││
│ │ } ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────┬───────────────────────────────────────┘
│ StateFlow<EditorContent>
▼
┌─────────────────────────────────────────────────────────────────┐
│ EditorView │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 1. Получаем новый EditorContent ││
│ │ 2. Вычисляем Diff между old и new content ││
│ │ 3. Применяем текстовые изменения (Editable.replace) ││
│ │ 4. Применяем стилевые изменения (Spannable.setSpan) ││
│ │ 5. Обновляем курсоры (CursorOverlay.invalidate) ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────────────┐ │
│ │ TextView │ │ CursorOverlay │ │ Toolbar │ │
│ │ (Spannable) │ │ (Custom View) │ │ (Formatting) │ │
│ └──────────────┘ └────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Ключевые принципы инкрементального обновления:
- Минимальные изменения: обновляем только те части текста и стилей, которые реально изменились
- Span adjustment: при вставке/удалении текста корректируем позиции всех span'ов
- Batch updates: группируем несколько изменений в один проход для минимизации invalidate()
- Cursor overlay: курсоры рисуются отдельным слоем, не влияя на текстовое представление
- Debounced rendering: частые обновления группируются и применяются с задержкой (например, 16ms для 60fps)
Кандидат продемонстрировал глубокое понимание проблемы: правильно выделил концепцию Diff, разделил типы обновлений (текст vs стиль), учёл необходимость пакетного применения изменений. Это уровень разработчика с опытом работы с производительными UI-интенсивными системами.
Вопрос 13. Как вы решите проблему conflict resolution, когда пользователь удалил текст и у него осталось 50 символов, а прилетает diff с ставкой текста на позицию 150?
Таймкод: 01:16:10
Ответ собеседния: Неполный. Кандидат признал, что это сложный вопрос, связанный с conflict resolution. Отметил, что в идеале эта логика должна быть на бэкенде, но в реальности это не всегда так. Предложил, что наиболее логичным вариантом было бы вставлять текст в конец документа, если указанная позиция не существует. Также упомянул, что конфликты стоит рассматривать в разных сценариях: два тользователя вставили текст в одну позицию, текст вставляется в несуществующую позицию, одновременное применение разных стилей и т.д. Кандидат не предложил конкретного алгоритма разрешения конфликтов (например, OT или CRDT), ограничившись общими соображениями и одним частным решением.
Правильный ответ:
Кандидат верно определил проблему, но ответ неполный. Вот комплексное решение проблемы conflict resolution в коллаборативном редакторе.
Типы конфликтов в коллаборативном редактировании
1. Position-based conflicts (описанный в вопросе):
- Пользователь A удалил текст, документ стал 50 символов
- Пользователь B вставляет текст на позицию 150
- Позиция 150 больше не существует
2. Concurrent edits at same position:
- Два пользователя одновременно вставляют текст в одну позицию
- Порядок применения влияет на результат
3. Overlapping style conflicts:
- Пользователь A применяет bold к диапазону [10, 20]
- Пользователь B применяет italic к диапазону [15, 25]
- Пересекающиеся стили должны корректно объединиться
4. Delete + Edit conflict:
- Пользователь A удаляет диапазон [10, 20]
- Пользователь B редактирует текст внутри этого диапазона
Подходы к разрешению конфликтов
Подход 1: Server-side Operational Transformation (OT)
Это классический подход, используемый в Google Docs.
Принцип работы:
- Все операции проходят через центральный сервер
- Сервер трансформирует операции относительно других параллельных операций
- Клиенты получают уже трансформированные операции
Пример трансформации:
// Исходный документ: "Hello World" (11 символов)
// Операция от пользователя A: Delete(5, 6) — удаляет " World"
// Операция от пользователя B: Insert(15, "!!!") — вставляет "!!!" на позицию 15
// Сервер получает обе операции и трансформирует:
// 1. Сначала применяем Delete(5, 6) -> документ становится "Hello" (5 символов)
// 2. Трансформируем Insert(15, "!!!") относительно Delete(5, 6):
// - Позиция 15 > 5 (начало удаления), поэтому корректируем
// - Новая позиция: 15 - 6 = 9
// - Но документ теперь 5 символов, поэтому ограничиваем до 5
// - Итог: Insert(5, "!!!") — вставляем в конец
// Результат: "Hello!!!"
Реализация OT на сервере:
// Серверная логика (концептуально)
class OperationalTransformationEngine {
private val document = StringBuilder()
private val history = mutableListOf<Operation>()
private var currentRevision = 0L
fun applyOperation(operation: Operation, clientId: String): TransformedOperation {
// 1. Находим все операции, которые были применены после базовой ревизии клиента
val concurrentOps = history.filter { it.revision > operation.baseRevision }
// 2. Трансформируем операцию относительно всех параллельных
var transformedOp = operation
for (concurrentOp in concurrentOps) {
transformedOp = transform(transformedOp, concurrentOp)
}
// 3. Применяем трансформированную операцию к документу
applyToDocument(transformedOp)
// 4. Сохраняем в историю
currentRevision++
transformedOp.revision = currentRevision
history.add(transformedOp)
return transformedOp
}
fun transform(op1: Operation, op2: Operation): Operation {
return when {
op1 is Insert && op2 is Insert -> transformInsertInsert(op1, op2)
op1 is Insert && op2 is Delete -> transformInsertDelete(op1, op2)
op1 is Delete && op2 is Insert -> transformDeleteInsert(op1, op2)
op1 is Delete && op2 is Delete -> transformDeleteDelete(op1, op2)
else -> op1
}
}
private fun transformInsertInsert(op1: Insert, op2: Insert): Insert {
return when {
op1.position < op2.position -> op1 // op1 не затронута
op1.position > op2.position -> {
// op1 сдвигается на длину вставки op2
op1.copy(position = op1.position + op2.text.length)
}
else -> {
// Одинаковая позиция — определяем порядок по clientId (детерминированно)
if (op1.clientId < op2.clientId) {
op1.copy(position = op1.position + op2.text.length)
} else {
op1
}
}
}
}
private fun transformInsertDelete(op1: Insert, op2: Delete): Insert {
return when {
op1.position <= op2.position -> op1 // Вставка до удаления
op1.position >= op2.position + op2.length -> {
// Вставка после удаления — сдвигаем назад
op1.copy(position = op1.position - op2.length)
}
else -> {
// Вставка внутри удаляемого диапазона — переносим в начало удаления
op1.copy(position = op2.position)
}
}
}
private fun transformDeleteInsert(op1: Delete, op2: Insert): Delete {
return when {
op1.position + op1.length <= op2.position -> op1 // Удаление до вставки
op1.position >= op2.position -> {
// Удаление после вставки — сдвигаем вперёд
op1.copy(position = op1.position + op2.text.length)
}
else -> {
// Пересечение — расширяем удаление
op1.copy(length = op1.length + op2.text.length)
}
}
}
private fun transformDeleteDelete(op1: Delete, op2: Delete): Delete {
val op1End = op1.position + op1.length
val op2End = op2.position + op2.length
return when {
op1End <= op2.position -> op1 // op1 полностью до op2
op1.position >= op2End -> {
// op1 полностью после op2 — сдвигаем
op1.copy(position = op1.position - op2.length)
}
op1.position <= op2.position && op1End >= op2End -> {
// op2 полностью внутри op1 — сокращаем op1
op1.copy(length = op1.length - op2.length)
}
op1.position < op2.position -> {
// Частичное пересечение в конце op1
op1.copy(length = op2.position - op1.position)
}
else -> {
// Частичное пересечение в начале op1
val newStart = op2End
val newLength = op1End - op2End
Delete(position = newStart, length = newLength)
}
}
}
}
sealed class Operation {
abstract val clientId: String
abstract val baseRevision: Long
var revision: Long = 0
}
data class Insert(
override val clientId: String,
override val baseRevision: Long,
val position: Int,
val text: String
) : Operation()
data class Delete(
override val clientId: String,
override val baseRevision: Long,
val position: Int,
val length: Int
) : Operation()
data class TransformedOperation(
val operation: Operation,
val newRevision: Long
)
Подход 2: CRDT (Conflict-free Replicated Data Types)
Используется в Figma, Notion, Yjs. Более современный подход, не требующий центрального сервера для разрешения конфликтов.
Принцип работы:
- Каждый символ имеет уникальный идентификатор
- Операции коммутативны и идемпотентны
- Конфликты разрешаются автоматически благодаря структуре данных
// Каждый символ имеет уникальный ID на основе позиции
data class CharId(
val clientId: String,
val clock: Long,
val position: Double // Дробное число для вставки между символами
)
data class Char(
val id: CharId,
val value: String,
val isDeleted: Boolean = false,
val styles: Set<TextStyle> = emptySet()
)
class CRDTDocument {
private val chars = TreeMap<CharId, Char>()
private var clock = 0L
fun insert(position: Int, text: String, clientId: String): List<InsertOperation> {
val operations = mutableListOf<InsertOperation>()
for (i in text.indices) {
clock++
val charId = generateCharId(position + i, clientId)
val char = Char(
id = charId,
value = text[i].toString()
)
chars[charId] = char
operations.add(InsertOperation(charId, char))
}
return operations
}
fun delete(position: Int, length: Int, clientId: String): List<DeleteOperation> {
val operations = mutableListOf<DeleteOperation>()
val visibleChars = getVisibleChars()
for (i in position until minOf(position + length, visibleChars.size)) {
val char = visibleChars[i]
chars[char.id] = char.copy(isDeleted = true)
operations.add(DeleteOperation(char.id))
}
return operations
}
fun applyRemoteOperation(operation: CRDTOperation) {
when (operation) {
is InsertOperation -> {
chars[operation.charId] = operation.char
}
is DeleteOperation -> {
chars[operation.charId]?.let { char ->
chars[operation.charId] = char.copy(isDeleted = true)
}
}
}
}
fun getText(): String {
return chars.values
.filter { !it.isDeleted }
.joinToString("") { it.value }
}
private fun generateCharId(position: Int, clientId: String): CharId {
val visibleChars = getVisibleChars()
val prevId = if (position > 0 && position <= visibleChars.size) {
visibleChars[position - 1].id
} else if (visibleChars.isNotEmpty()) {
visibleChars.last().id
} else {
CharId("", 0.0, 0.0)
}
val nextId = if (position < visibleChars.size) {
visibleChars[position].id
} else {
CharId("~", Double.MAX_VALUE, Double.MAX_VALUE)
}
// Генерируем позицию между prevId и nextId
val newPosition = (prevId.position + nextId.position) / 2.0
return CharId(clientId, clock.toDouble(), newPosition)
}
private fun getVisibleChars(): List<Char> {
return chars.values.filter { !it.isDeleted }.toList()
}
}
sealed class CRDTOperation
data class InsertOperation(val charId: CharId, val char: Char) : CRDTOperation()
data class DeleteOperation(val charId: CharId) : CRDTOperation()
Подход 3: Client-side fallback (для описанного в вопросе сценария)
Когда сервер не обеспечивает полную OT/CRDT трансформацию, клиент должен иметь fallback-логику:
class ConflictResolver {
fun resolveIncomingUpdate(
currentContent: EditorContent,
incomingUpdate: TextUpdate
): TextUpdate {
val textLength = currentContent.plainText.length
return when (incomingUpdate.type) {
TextUpdateType.INSERT -> {
if (incomingUpdate.position > textLength) {
// Позиция за пределами документа — корректируем
incomingUpdate.copy(position = textLength)
} else {
incomingUpdate
}
}
TextUpdateType.DELETE -> {
if (incomingUpdate.position >= textLength) {
// Попытка удалить за пределами документа — игнорируем
incomingUpdate.copy(position = textLength, length = 0)
} else {
val adjustedLength = minOf(
incomingUpdate.length,
textLength - incomingUpdate.position
)
incomingUpdate.copy(length = adjustedLength)
}
}
TextUpdateType.REPLACE -> {
// Аналогичная логика для замены
resolveReplace(currentContent, incomingUpdate)
}
}
}
private fun resolveReplace(
content: EditorContent,
update: TextUpdate
): TextUpdate {
val textLength = content.plainText.length
val adjustedPosition = minOf(update.position, textLength)
val adjustedOldLength = minOf(
update.length,
textLength - adjustedPosition
)
return update.copy(
position = adjustedPosition,
length = adjustedOldLength
)
}
// Стратегия "Best effort" — применить что можно
fun applyBestEffort(
content: EditorContent,
update: TextUpdate
): EditorContent {
val textLength = content.plainText.length
return when (update.type) {
TextUpdateType.INSERT -> {
val safePosition = update.position.coerceIn(0, textLength)
val newText = content.plainText.substring(0, safePosition) +
update.content +
content.plainText.substring(safePosition)
content.copy(
plainText = newText,
spans = adjustSpans(content.spans, safePosition, update.content.length)
)
}
TextUpdateType.DELETE -> {
if (update.position >= textLength) {
content // Нечего удалять
} else {
val safeLength = minOf(update.length, textLength - update.position)
val newText = content.plainText.substring(0, update.position) +
content.plainText.substring(update.position + safeLength)
content.copy(
plainText = newText,
spans = adjustSpansForDelete(content.spans, update.position, safeLength)
)
}
}
else -> content
}
}
private fun adjustSpans(
spans: List<StyledSpan>,
insertPos: Int,
insertLen: Int
): List<StyledSpan> {
return spans.mapNotNull { span ->
when {
span.end <= insertPos -> span
span.start >= insertPos -> span.copy(
start = span.start + insertLen,
end = span.end + insertLen
)
else -> span.copy(end = span.end + insertLen)
}
}
}
private fun adjustSpansForDelete(
spans: List<StyledSpan>,
deletePos: Int,
deleteLen: Int
): List<StyledSpan> {
return spans.mapNotNull { span ->
val deleteEnd = deletePos + deleteLen
when {
span.end <= deletePos -> span
span.start >= deleteEnd -> span.copy(
start = span.start - deleteLen,
end = span.end - deleteLen
)
span.start >= deletePos && span.end <= deleteEnd -> null // Полностью удалён
span.start < deletePos && span.end > deleteEnd -> span.copy(
end = span.end - deleteLen
)
span.start < deletePos -> span.copy(end = deletePos)
else -> span.copy(start = deletePos, end = deletePos + (span.end - deleteEnd))
}
}
}
}
Рекомендуемая архитектура для production:
class DocumentSynchronizer(
private val webSocketService: WebSocketService,
private val conflictResolver: ConflictResolver,
private val localDocument: CRDTDocument // или OT-based document
) {
private var lastSyncedRevision = 0L
private val pendingOperations = mutableListOf<Operation>()
fun init(documentId: String) {
webSocketService.connect(documentId)
// Подписка на входящие операции
viewModelScope.launch {
webSocketService.incomingMessages.collect { message ->
when (message) {
is ServerMessage.DocumentUpdate -> {
handleIncomingUpdate(message.update)
}
is ServerMessage.Acknowledged -> {
handleAcknowledgment(message.revision)
}
// ... другие типы сообщений
}
}
}
}
fun applyLocalChange(operation: Operation) {
// 1. Применяем локально
localDocument.applyOperation(operation)
// 2. Добавляем в очередь для отправки
pendingOperations.add(operation)
// 3. Отправляем на сервер
webSocketService.send(ClientMessage.TextChange(operation))
}
private fun handleIncomingUpdate(update: IncomingUpdate) {
when (update) {
is IncomingUpdate.TextUpdate -> {
// Проверяем, является ли это нашей собственной операцией
if (update.userId == ourUserId) {
// Это подтверждение нашей операции
return
}
// Применяем удалённую операцию
val operation = update.update
// Если сервер поддерживает OT — просто применяем
// Если нет — используем conflict resolver
if (serverSupportsOT) {
localDocument.applyOperation(operation)
} else {
val resolved = conflictResolver.resolveIncomingUpdate(
localDocument.getContent(),
operation
)
localDocument.applyOperation(resolved)
}
}
// ... другие типы обновлений
}
}
private fun handleAcknowledgment(revision: Long) {
lastSyncedRevision = revision
pendingOperations.removeAll { it.revision <= revision }
}
}
Сравнение подходов:
| Критерий | OT | CRDT | Client-side fallback |
|---|---|---|---|
| Требует центральный сервер | Да | Нет | Нет |
| Сложность реализации | Высокая | Очень высокая | Средняя |
| Производительность | Высокая | Средняя (метаданные) | Высокая |
| Надёжность | Зависит от сервера | Высокая | Средняя |
| Подходит для offline | Ограничено | Да | Да |
| Примеры использования | Google Docs | Figma, Notion | Простые редакторы |
Рекомендация:
Для production-решения коллаборативного редактора:
- Использовать CRDT (например, Yjs, Automerge) для offline-first подхода и автоматического разрешения конфликтов
- Если CRDT невозможен — реализовать OT на сервере с client-side fallback для edge-случаев
- Всегда иметь client-side validation — проверять границы позиций перед применением операций
Кандидат правильно определил, что это проблема conflict resolution, и предложил логичный fallback (вставка в конец документа). Однако для полного ответа необходимо знать алгоритмы OT/CRDT и понимать, как они работают на практике.
Вопрос 14. Что бы вы хотели дорисовать или обсудить, если бы хватило времени?
Таймкод: 01:23:51
Ответ собеседника: Правильный. Кандидат отметил, что не успели поговорить про шаринг (sharing), хотя это было в требованиях. Также очень слабо затронули вопрос взаимодействия с оффлайн-режимом и поддержки оффлайн-редактирования. По списку документов тоже стоило поговорить детальнее. Кандидат считает эти пробелы существенными.
Правильный ответ:
Кандидат правильно выделил три ключевых пробела. Вот детальное описание каждого из них с архитектурными решениями.
1. Sharing (шаринг и управление доступом)
Функциональные требования:
// Модели данных для шаринга
data class DocumentPermission(
val documentId: String,
val userId: String,
val role: PermissionRole,
val grantedBy: String,
val grantedAt: Long
)
enum class PermissionRole {
OWNER, // Полный контроль, может удалять документ
EDITOR, // Может редактировать контент
COMMENTER, // Может комментировать
VIEWER // Только чтение
}
data class ShareLink(
val documentId: String,
val linkId: String,
val role: PermissionRole,
val expiresAt: Long?, // Опциональное истечение
val password: String?, // Опциональный пароль
val createdBy: String,
val createdAt: Long
)
sealed class ShareResult {
data class Success(val link: String) : ShareResult()
data class PermissionDenied(val message: String) : ShareResult()
data class Error(val exception: Throwable) : ShareResult()
}
Архитектура шаринга:
// Domain Layer
class ShareDocumentUseCase(
private val documentsRepository: DocumentsRepository,
private val permissionsRepository: PermissionsRepository
) {
suspend fun shareViaLink(
documentId: String,
role: PermissionRole,
expiresIn: Duration? = null
): Result<ShareLink> {
// Проверяем права текущего пользователя
val currentPermission = permissionsRepository.getPermission(
documentId,
getCurrentUserId()
)
if (currentPermission?.role != PermissionRole.OWNER) {
return Result.failure(PermissionDeniedException())
}
// Создаём ссылку
val shareLink = ShareLink(
documentId = documentId,
linkId = generateLinkId(),
role = role,
expiresAt = expiresIn?.let { System.currentTimeMillis() + it.inWholeMilliseconds },
password = null,
createdBy = getCurrentUserId(),
createdAt = System.currentTimeMillis()
)
return permissionsRepository.createShareLink(shareLink)
}
suspend fun shareWithUser(
documentId: String,
userEmail: String,
role: PermissionRole
): Result<Unit> {
// Находим пользователя по email
val user = usersRepository.findByEmail(userEmail)
?: return Result.failure(UserNotFoundException())
// Проверяем права
if (!hasPermission(documentId, PermissionRole.OWNER)) {
return Result.failure(PermissionDeniedException())
}
// Выдаём права
return permissionsRepository.grantPermission(
DocumentPermission(
documentId = documentId,
userId = user.id,
role = role,
grantedBy = getCurrentUserId(),
grantedAt = System.currentTimeMillis()
)
)
}
suspend fun revokeAccess(
documentId: String,
userId: String
): Result<Unit> {
if (!hasPermission(documentId, PermissionRole.OWNER)) {
return Result.failure(PermissionDeniedException())
}
return permissionsRepository.revokePermission(documentId, userId)
}
}
// Проверка прав при каждом действии
class CheckPermissionUseCase(
private val permissionsRepository: PermissionsRepository
) {
suspend fun canEdit(documentId: String): Boolean {
val permission = permissionsRepository.getPermission(documentId, getCurrentUserId())
return permission?.role in listOf(PermissionRole.OWNER, PermissionRole.EDITOR)
}
suspend fun canShare(documentId: String): Boolean {
val permission = permissionsRepository.getPermission(documentId, getCurrentUserId())
return permission?.role == PermissionRole.OWNER
}
}
UI для шаринга:
class ShareViewModel @Inject constructor(
private val shareDocumentUseCase: ShareDocumentUseCase,
private val getDocumentPermissionsUseCase: GetDocumentPermissionsUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ShareState())
val state: StateFlow<ShareState> = _state.asStateFlow()
fun loadPermissions(documentId: String) {
viewModelScope.launch {
getDocumentPermissionsUseCase(documentId)
.onSuccess { permissions ->
_state.update { it.copy(permissions = permissions) }
}
}
}
fun shareViaLink(role: PermissionRole) {
viewModelScope.launch {
shareDocumentUseCase.shareViaLink(
documentId = _state.value.documentId,
role = role
).onSuccess { link ->
_state.update {
it.copy(
shareLink = link,
shareUrl = generateShareUrl(link.linkId)
)
}
}
}
}
fun shareWithUser(email: String, role: PermissionRole) {
viewModelScope.launch {
shareDocumentUseCase.shareWithUser(
documentId = _state.value.documentId,
userEmail = email,
role = role
).onSuccess {
// Показываем успех, обновляем список
loadPermissions(_state.value.documentId)
}
}
}
}
data class ShareState(
val documentId: String = "",
val permissions: List<DocumentPermissionUi> = emptyList(),
val shareLink: ShareLink? = null,
val shareUrl: String? = null,
val isLoading: Boolean = false,
val error: String? = null
)
Серверная реализация (концептуально):
-- Таблица прав доступа
CREATE TABLE document_permissions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id),
user_id UUID NOT NULL REFERENCES users(id),
role VARCHAR(20) NOT NULL,
granted_by UUID NOT NULL,
granted_at TIMESTAMP NOT NULL,
UNIQUE(document_id, user_id)
);
-- Таблица шаринг-ссылок
CREATE TABLE share_links (
link_id VARCHAR(50) PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id),
role VARCHAR(20) NOT NULL,
expires_at TIMESTAMP,
password_hash VARCHAR(255),
created_by UUID NOT NULL,
created_at TIMESTAMP NOT NULL
);
-- Проверка прав при запросе документа
CREATE OR REPLACE FUNCTION check_document_access(
p_document_id UUID,
p_user_id UUID
) RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM document_permissions
WHERE document_id = p_document_id AND user_id = p_user_id
) OR EXISTS (
SELECT 1 FROM documents
WHERE id = p_document_id AND owner_id = p_user_id
);
END;
$$ LANGUAGE plpgsql;
2. Оффлайн-режим и синхронизация
Архитектура offline-first:
// Модель для хранения pending изменений
@Entity(tableName = "pending_changes")
data class PendingChangeEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val documentId: String,
val operationType: String, // INSERT, DELETE, REPLACE
val position: Int,
val content: String?,
val length: Int,
val baseRevision: Long,
val timestamp: Long = System.currentTimeMillis(),
val synced: Boolean = false,
val retryCount: Int = 0
)
@Dao
interface PendingChangesDao {
@Query("SELECT * FROM pending_changes WHERE documentId = :documentId AND synced = 0 ORDER BY timestamp")
suspend fun getPendingChanges(documentId: String): List<PendingChangeEntity>
@Insert
suspend fun insert(change: PendingChangeEntity)
@Query("UPDATE pending_changes SET synced = 1 WHERE id = :id")
suspend fun markAsSynced(id: String)
@Query("UPDATE pending_changes SET retryCount = retryCount + 1 WHERE id = :id")
suspend fun incrementRetryCount(id: String)
@Query("DELETE FROM pending_changes WHERE documentId = :documentId AND synced = 1")
suspend fun clearSynced(documentId: String)
}
// Стратегия синхронизации
class OfflineSyncStrategy(
private val pendingChangesDao: PendingChangesDao,
private val networkMonitor: NetworkMonitor,
private val webSocketService: WebSocketService,
private val conflictResolver: ConflictResolver
) {
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun startSync(documentId: String) {
syncScope.launch {
// Слушаем восстановление сети
networkMonitor.isOnline.collect { isOnline ->
if (isOnline) {
syncPendingChanges(documentId)
}
}
}
}
suspend fun syncPendingChanges(documentId: String) {
val pendingChanges = pendingChangesDao.getPendingChanges(documentId)
if (pendingChanges.isEmpty()) return
// Группируем изменения для пакетной отправки
val batches = pendingChanges.chunked(10)
for (batch in batches) {
try {
// Отправляем на сервер
val result = webSocketService.sendBatch(batch.map { it.toOperation() })
result.onSuccess { serverResponse ->
// Получаем актуальную ревизию
val serverRevision = serverResponse.revision
val serverChanges = serverResponse.changes
// Применяем серверные изменения (от других пользователей)
for (serverChange in serverChanges) {
val resolved = conflictResolver.resolveIncomingUpdate(
getCurrentContent(),
serverChange
)
applyToDocument(resolved)
}
// Помечаем как синхронизированные
batch.forEach { change ->
pendingChangesDao.markAsSynced(change.id)
}
// Обновляем ревизию
updateLocalRevision(documentId, serverRevision)
}.onFailure { error ->
// Увеличиваем счётчик попыток
batch.forEach { change ->
pendingChangesDao.incrementRetryCount(change.id)
}
// Если слишком много попыток — помечаем для ручного разрешения
if (batch.any { it.retryCount > 3 }) {
markForManualResolution(batch)
}
}
} catch (e: Exception) {
// Сеть снова пропала — прекращаем синхронизацию
break
}
}
}
private fun markForManualResolution(changes: List<PendingChangeEntity>) {
// Сохраняем конфликт для ручного разрешения пользователем
// Показываем UI с возможностью выбрать версию
}
}
// Расширенный репозиторий с поддержкой офлайн
class OfflineFirstDocumentsRepository(
private val remoteDataSource: DocumentsRemoteDataSource,
private val localDataSource: DocumentsLocalDataSource,
private val pendingChangesDao: PendingChangesDao,
private val networkMonitor: NetworkMonitor
) : DocumentsRepository {
override suspend fun getDocument(documentId: String): Result<Document> {
// Всегда сначала из локального хранилища
val localDocument = localDataSource.getDocument(documentId)
// Если онлайн — обновляем с сервера
if (networkMonitor.isOnline()) {
viewModelScope.launch {
try {
val remoteDocument = remoteDataSource.getDocument(documentId)
localDataSource.saveDocument(remoteDocument)
} catch (e: Exception) {
// Используем локальную версию
}
}
}
return localDocument?.let { Result.success(it) }
?: Result.failure(DocumentNotFoundException())
}
override suspend fun saveDocument(document: Document): Result<Unit> {
// Всегда сохраняем локально
localDataSource.saveDocument(document)
// Добавляем в очередь для синхронизации
val pendingChange = PendingChangeEntity(
documentId = document.id,
operationType = "SAVE",
position = 0,
content = document.content.toJson(),
length = 0,
baseRevision = document.revision
)
pendingChangesDao.insert(pendingChange)
// Если онлайн — сразу пытаемся синхронизировать
if (networkMonitor.isOnline()) {
syncPendingChanges(document.id)
}
return Result.success(Unit)
}
override suspend fun applyLocalChange(
documentId: String,
change: TextChange
): Result<Unit> {
// Применяем локально
localDataSource.applyChange(documentId, change)
// Сохраняем в pending
val pendingChange = PendingChangeEntity(
documentId = documentId,
operationType = change.type.name,
position = change.position,
content = change.content,
length = change.length,
baseRevision = getLocalRevision(documentId)
)
pendingChangesDao.insert(pendingChange)
return Result.success(Unit)
}
}
UI-индикация оффлайн-статуса:
@Composable
fun EditorStatusBar(
state: EditorUiState
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Статус подключения
Row(verticalAlignment = Alignment.CenterVertically) {
val connectionColor = when {
state.isConnected -> Color.Green
state.hasUnsavedChanges -> Color.Yellow
else -> Color.Red
}
Box(
modifier = Modifier
.size(8.dp)
.background(connectionColor, CircleShape)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = when {
state.isConnected -> "Подключено"
state.hasUnsavedChanges -> "Ожидание синхронизации (${state.unsavedCount})"
else -> "Оффлайн"
},
style = MaterialTheme.typography.caption
)
}
// Статус сохранения
if (state.isSaving) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(4.dp))
Text("Сохранение...", style = MaterialTheme.typography.caption)
}
}
// Количество редакторов
if (state.collaborators.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
"${state.collaborators.size + 1} редакторов",
style = MaterialTheme.typography.caption
)
}
}
}
}
3. Детализация списка документов
Расширенная архитектура:
// Расширенное состояние списка документов
data class DocumentsListState(
val documents: List<DocumentItemUi> = emptyList(),
val viewMode: ViewMode = ViewMode.LIST,
val sortOrder: SortOrder = SortOrder.LAST_MODIFIED,
val filter: DocumentFilter = DocumentFilter(),
val searchQuery: String = "",
val selectedDocumentIds: Set<String> = emptySet(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMorePages: Boolean = true,
val error: String? = null,
val syncStatus: SyncStatus = SyncStatus.SYNCED
)
enum class ViewMode { LIST, GRID }
enum class SortOrder { LAST_MODIFIED, TITLE, CREATED, SIZE }
enum class SyncStatus { SYNCED, SYNCING, OFFLINE, ERROR }
data class DocumentFilter(
val showOwnedByMe: Boolean = true,
val showSharedWithMe: Boolean = true,
val dateRange: DateRange? = null
)
data class DateRange(
val from: Long,
val to: Long
)
data class DocumentItemUi(
val id: String,
val title: String,
val preview: String,
val lastModified: Long,
val createdAt: Long,
val ownerName: String,
val collaboratorsCount: Int,
val thumbnailUrl: String?,
val isOfflineAvailable: Boolean,
val syncStatus: SyncStatus,
val unreadChanges: Boolean = false
)
// Расширенные события
sealed class DocumentsListEvent {
object LoadDocuments : DocumentsListEvent()
object RefreshDocuments : DocumentsListEvent()
object LoadMore : DocumentsListEvent()
data class SearchDocuments(val query: String) : DocumentsListEvent()
data class SortDocuments(val order: SortOrder) : DocumentsListEvent()
data class FilterDocuments(val filter: DocumentFilter) : DocumentsListEvent()
data class OpenDocument(val documentId: String) : DocumentsListEvent()
data class CreateDocument(val title: String?) : DocumentsListEvent()
data class DeleteDocuments(val ids: Set<String>) : DocumentsListEvent()
data class ShareDocument(val documentId: String) : DocumentsListEvent()
data class ToggleOfflineAvailability(val documentId: String) : DocumentsListEvent()
data class ToggleSelection(val documentId: String) : DocumentsListEvent()
object ClearSelection : DocumentsListEvent()
data class ViewModeChanged(val mode: ViewMode) : DocumentsListEvent()
}
// ViewModel с расширенной функциональностью
class DocumentsListViewModel @Inject constructor(
private val getDocumentsUseCase: GetDocumentsUseCase,
private val createDocumentUseCase: CreateDocumentUseCase,
private val deleteDocumentsUseCase: DeleteDocumentsUseCase,
private val searchDocumentsUseCase: SearchDocumentsUseCase,
private val toggleOfflineUseCase: ToggleOfflineAvailabilityUseCase
) : ViewModel() {
private val _state = MutableStateFlow(DocumentsListState())
val state: StateFlow<DocumentsListState> = _state.asStateFlow()
private val _events = MutableSharedFlow<DocumentsListEvent>()
val events: SharedFlow<DocumentsListEvent> = _events.asSharedFlow()
// Пагинация
private val paginationManager = PaginationManager(
coroutineScope = viewModelScope,
loadPage = { cursor, limit ->
getDocumentsUseCase(cursor, limit)
}
)
fun onEvent(event: DocumentsListEvent) {
when (event) {
is DocumentsListEvent.LoadDocuments -> loadDocuments()
is DocumentsListEvent.RefreshDocuments -> refreshDocuments()
is DocumentsListEvent.LoadMore -> loadMore()
is DocumentsListEvent.SearchDocuments -> search(event.query)
is DocumentsListEvent.SortDocuments -> sort(event.order)
is DocumentsListEvent.FilterDocuments -> filter(event.filter)
is DocumentsListEvent.OpenDocument -> openDocument(event.documentId)
is DocumentsListEvent.CreateDocument -> createDocument(event.title)
is DocumentsListEvent.DeleteDocuments -> deleteDocuments(event.ids)
is DocumentsListEvent.ShareDocument -> shareDocument(event.documentId)
is DocumentsListEvent.ToggleOfflineAvailability -> toggleOffline(event.documentId)
is DocumentsListEvent.ToggleSelection -> toggleSelection(event.documentId)
is DocumentsListEvent.ClearSelection -> clearSelection()
is DocumentsListEvent.ViewModeChanged -> changeViewMode(event.mode)
}
}
private fun loadDocuments() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
paginationManager.loadFirstPage()
.onSuccess { documents ->
_state.update {
it.copy(
documents = documents.map { doc -> doc.toUi() },
isLoading = false,
hasMorePages = documents.size >= PAGE_SIZE
)
}
}
.onFailure { error ->
_state.update {
it.copy(
isLoading = false,
error = error.message
)
}
}
}
}
private fun search(query: String) {
viewModelScope.launch {
_state.update { it.copy(searchQuery = query) }
if (query.isBlank()) {
loadDocuments()
return@launch
}
searchDocumentsUseCase(query)
.onSuccess { results ->
_state.update {
it.copy(documents = results.map { doc -> doc.toUi() })
}
}
}
}
private fun sort(order: SortOrder) {
_state.update {
it.copy(
sortOrder = order,
documents = sortDocuments(it.documents, order)
)
}
}
private fun sortDocuments(
documents: List<DocumentItemUi>,
order: SortOrder
): List<DocumentItemUi> {
return when (order) {
SortOrder.LAST_MODIFIED -> documents.sortedByDescending { it.lastModified }
SortOrder.TITLE -> documents.sortedBy { it.title.lowercase() }
SortOrder.CREATED -> documents.sortedByDescending { it.createdAt }
SortOrder.SIZE -> documents.sortedByDescending { it.preview.length }
}
}
private fun toggleOffline(documentId: String) {
viewModelScope.launch {
toggleOfflineUseCase(documentId)
.onSuccess { isAvailable ->
_state.update { state ->
state.copy(
documents = state.documents.map { doc ->
if (doc.id == documentId) {
doc.copy(isOfflineAvailable = isAvailable)
} else {
doc
}
}
)
}
}
}
}
private fun deleteDocuments(ids: Set<String>) {
viewModelScope.launch {
deleteDocumentsUseCase(ids)
.onSuccess {
_state.update { state ->
state.copy(
documents = state.documents.filter { it.id !in ids },
selectedDocumentIds = emptySet()
)
}
}
}
}
}
// UI списка документов
@Composable
fun DocumentsListScreen(
viewModel: DocumentsListViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
DocumentsTopBar(
searchQuery = state.searchQuery,
onSearchChanged = { viewModel.onEvent(DocumentsListEvent.SearchDocuments(it)) },
viewMode = state.viewMode,
onViewModeChanged = { viewModel.onEvent(DocumentsListEvent.ViewModeChanged(it)) },
onSortClicked = { /* показать bottom sheet с сортировкой */ }
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { viewModel.onEvent(DocumentsListEvent.CreateDocument(null)) }
) {
Icon(Icons.Default.Add, contentDescription = "Создать документ")
}
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
when {
state.isLoading -> LoadingIndicator()
state.error != null -> ErrorView(
error = state.error,
onRetry = { viewModel.onEvent(DocumentsListEvent.LoadDocuments) }
)
state.documents.isEmpty() -> EmptyState(
onCreateDocument = { viewModel.onEvent(DocumentsListEvent.CreateDocument(null)) }
)
else -> DocumentsList(
documents = state.documents,
viewMode = state.viewMode,
selectedIds = state.selectedDocumentIds,
onDocumentClick = { viewModel.onEvent(DocumentsListEvent.OpenDocument(it)) },
onDocumentLongClick = { viewModel.onEvent(DocumentsListEvent.ToggleSelection(it)) },
onLoadMore = { viewModel.onEvent(DocumentsListEvent.LoadMore) },
hasMorePages = state.hasMorePages
)
}
}
}
}
@Composable
fun DocumentItem(
document: DocumentItemUi,
isSelected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
colors = if (isSelected) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
CardDefaults.cardColors()
}
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = document.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
// Индикаторы статуса
Row {
if (document.isOfflineAvailable) {
Icon(
Icons.Default.CloudOff,
contentDescription = "Доступен офлайн",
modifier = Modifier.size(16.dp)
)
}
if (document.unreadChanges) {
Badge { Text("!") }
}
when (document.syncStatus) {
SyncStatus.SYNCING -> {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
}
SyncStatus.ERROR -> {
Icon(
Icons.Default.Error,
contentDescription = "Ошибка синхронизации",
tint = Color.Red,
modifier = Modifier.size(16.dp)
)
}
else -> {}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = document.preview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Изменено ${formatRelativeTime(document.lastModified)}",
style = MaterialTheme.typography.caption
)
if (document.collaboratorsCount > 0) {
Text(
text = "${document.collaboratorsCount} редакторов",
style = MaterialTheme.typography.caption
)
}
}
}
}
}
Итого по пробелам:
Кандидат правильно выделил три существенных пробела:
- Sharing — критичная фича для коллаборативного редактора, требует проектирования системы прав доступа, шаринг-ссылок и UI для управления доступом
- Оффлайн-режим — одна из самых сложных частей коллаборативного редактора, требует реализации pending changes, стратегии синхронизации и conflict resolution
- Список документов — базовый экран, который нужно детализировать: поиск, сортировка, фильтрация, мультивыбор, индикация статуса синхронизации
Способность кандидата самостоятельно выявить пробелы в собственном проектировании демонстрирует критическое мышление и понимание того, что System Design — это итеративный процесс, а не попытка за один раз охватить всё.
Вопрос 15. Что бы вы изменили в процессе проектирования или в самой схеме, если бы могли?
Таймкод: 01:25:05
Ответ собеседника: Правильный. Кандидат выделил следующие минусы: получилось несколько отдельных схем (верхний уровень, список документов, редактирование) вместо одной целостной — хотелось бы единое решение, где система работает совместно. Стоило больше подумать над моделями данных, чтобы создать универсальный Update-модель, которую можно было бы переиспользовать везде, вместо создания разных моделей для разных слоёв. Также стоило обсудить шаринг — один из пунктов требований вообще не был затронут и не отображён на схеме.
Правильный ответ:
Кандидат провёл отличную рефлексию. Вот развёрнутый анализ его замечаний и рекомендации по улучшению процесса проектирования.
Проблема 1: Разрозненные схемы вместо единой архитектуры
Что произошло:
Кандидат спроектировал три отдельные схемы:
- Высокоуровневая архитектура приложения
- Детализация списка документов
- Детализация экрана редактирования
Каждая схема хороша сама по себе, но они не складываются в единую картину.
Как исправить: единая архитектурная схема с уровнями детализации
Подход с многоуровневыми диаграми (C4-модель):
Уровень 1 — System Context:
┌─────────────────────────────────────────────────────────────────┐
│ Users │
│ (Mobile App, Web App, Desktop) │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Document Editor System │
│ (Mobile App + Backend) │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ External Services │
│ (Auth0, Push Notifications, Analytics) │
└─────────────────────────────────────────────────────────────────┘
Уровень 2 — Container Diagram:
┌─────────────────────────────────────────────────────────────────┐
│ Mobile App (Android) │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Presentation Layer ││
│ │ ┌───────────────┐ ┌───────────────┐ ┌────────────────┐ ││
│ │ │ DocumentsList │ │ Editor │ │ Share │ ││
│ │ │ Screen │ │ Screen │ │ Screen │ ││
│ │ └───────────────┘ └───────────────┘ └────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Domain Layer ││
│ │ ┌─────────────────────────────────────────────────────┐ ││
│ │ │ Use Cases │ ││
│ │ └─────────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Data Layer ││
│ │ ┌───────────────┐ ┌───────────────┐ ┌────────────────┐ ││
│ │ │ Repository │ │ Remote │ │ Local │ ││
│ │ │ │ │ DataSource │ │ DataSource │ ││
│ │ └───────────────┘ └───────────────┘ └────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────┬───────────────────────────────────────┘
│ WebSocket + REST
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ API Gateway │ │Collaboration│ │ Documents Service │ │
│ │ │ │ Service │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Уровень 3 — Component Diagram (для конкретного флоу):
┌─────────────────────────────────────────────────────────────────┐
│ Editor Flow │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ EditorViewModel ││
│ │ ┌───────────┐ ┌───────────┐ ┌─────────────────────────┐ ││
│ │ │TextBuffer │ │StyleMgr │ │ SyncCoordinator │ ││
│ │ │(debounce) │ │(immediate)│ │ (WebSocket) │ ││
│ │ └───────────┘ └───────────┘ └─────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ EditorUseCase ││
│ │ ┌───────────┐ ┌───────────┐ ┌─────────────────────────┐ ││
│ │ │ApplyFormat│ │SyncDoc │ │ ResolveConflict │ ││
│ │ └───────────┘ └───────────┘ └─────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
Рекомендация для процесса:
Начинать с единой высокоуровневой схемы, а затем углубляться в компоненты, сохраняя связи между ними. Использовать единую нотацию и соглашения об именовании.
Проблема 2: Разные модели данных для разных слоёв
Что произошло:
Кандидат создавал отдельные модели для каждого слоя:
TextUpdateдля domainTextDiffUpdateдля UIPendingChangeEntityдля data
Это приводит к дублированию и сложностям при маппинге.
Как исправить: единая Update-модель с адаптерами
// Единая модель обновления (core domain)
sealed class DocumentUpdate {
abstract val id: String
abstract val documentId: String
abstract val userId: String
abstract val timestamp: Long
abstract val baseRevision: Long
data class TextInsert(
override val id: String = UUID.randomUUID().toString(),
override val documentId: String,
override val userId: String,
override val timestamp: Long = System.currentTimeMillis(),
override val baseRevision: Long,
val position: Int,
val text: String
) : DocumentUpdate()
data class TextDelete(
override val id: String = UUID.randomUUID().toString(),
override val documentId: String,
override val userId: String,
override val timestamp: Long = System.currentTimeMillis(),
override val baseRevision: Long,
val position: Int,
val length: Int
) : DocumentUpdate()
data class StyleApply(
override val id: String = UUID.randomUUID().toString(),
override val documentId: String,
override val userId: String,
override val timestamp: Long = System.currentTimeMillis(),
override val baseRevision: Long,
val style: TextStyle,
val start: Int,
val end: Int
) : DocumentUpdate()
data class MetadataUpdate(
override val id: String = UUID.randomUUID().toString(),
override val documentId: String,
override val userId: String,
override val timestamp: Long = System.currentTimeMillis(),
override val baseRevision: Long,
val title: String? = null,
val isPublic: Boolean? = null
) : DocumentUpdate()
}
// Адаптер для UI
fun DocumentUpdate.toUiModel(): UpdateUiModel {
return when (this) {
is DocumentUpdate.TextInsert -> UpdateUiModel.TextInsert(
id = id,
position = position,
text = text,
userId = userId,
timestamp = timestamp
)
is DocumentUpdate.TextDelete -> UpdateUiModel.TextDelete(
id = id,
position = position,
length = length,
userId = userId,
timestamp = timestamp
)
is DocumentUpdate.StyleApply -> UpdateUiModel.StyleApply(
id = id,
style = style,
start = start,
end = end,
userId = userId,
timestamp = timestamp
)
is DocumentUpdate.MetadataUpdate -> UpdateUiModel.MetadataUpdate(
id = id,
title = title,
userId = userId,
timestamp = timestamp
)
}
}
// Адаптер для базы данных
fun DocumentUpdate.toEntity(): PendingChangeEntity {
return PendingChangeEntity(
id = id,
documentId = documentId,
userId = userId,
operationType = when (this) {
is DocumentUpdate.TextInsert -> "INSERT"
is DocumentUpdate.TextDelete -> "DELETE"
is DocumentUpdate.StyleApply -> "STYLE"
is DocumentUpdate.MetadataUpdate -> "METADATA"
},
position = when (this) {
is DocumentUpdate.TextInsert -> position
is DocumentUpdate.TextDelete -> position
is DocumentUpdate.StyleApply -> start
is DocumentUpdate.MetadataUpdate -> 0
},
content = when (this) {
is DocumentUpdate.TextInsert -> text
is DocumentUpdate.TextDelete -> null
is DocumentUpdate.StyleApply -> style.name
is DocumentUpdate.MetadataUpdate -> title
},
length = when (this) {
is DocumentUpdate.TextDelete -> length
is DocumentUpdate.StyleApply -> end - start
else -> 0
},
baseRevision = baseRevision,
timestamp = timestamp
)
}
// Адаптер для API
fun DocumentUpdate.toApiModel(): ApiUpdate {
return when (this) {
is DocumentUpdate.TextInsert -> ApiUpdate.TextInsert(
position = position,
text = text,
revision = baseRevision
)
is DocumentUpdate.TextDelete -> ApiUpdate.TextDelete(
position = position,
length = length,
revision = baseRevision
)
is DocumentUpdate.StyleApply -> ApiUpdate.StyleApply(
style = style.toApi(),
start = start,
end = end,
revision = baseRevision
)
is DocumentUpdate.MetadataUpdate -> ApiUpdate.MetadataUpdate(
title = title,
revision = baseRevision
)
}
}
Проблема 3: Не затронут шаринг
Как исправить: добавить шаринг в единую архитектуру
// Добавляем в высокоуровневую архитектуру
┌─────────────────────────────────────────────────────────────────┐
│ Mobile App │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Presentation Layer ││
│ │ ┌───────────┐ ┌───────────┐ ┌─────────────────────────┐ ││
│ │ │ Documents │ │ Editor │ │ Share │ ││
│ │ │ List │ │ Screen │ │ Screen │ ││
│ │ └───────────┘ └───────────┘ └─────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Domain Layer ││
│ │ ┌───────────┐ ┌───────────┐ ┌─────────────────────────┐ ││
│ │ │ Document │ │ Editor │ │ Sharing │ ││
│ │ │ UseCases │ │ UseCases │ │ UseCases │ ││
│ │ └───────────┘ └───────────┘ └─────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
Рекомендации для процесса проектирования
1. Начинать с единой схемы:
- Нарисовать общую архитектуру на одной доске
- Выделить основные компоненты и связи между ними
- Обозначить границы слоёв
2. Определить ключевые модели данных заранее:
- Создать глоссарий терминов
- Определить базовые модели (Document, User, Update)
- Установить правила маппинга между слоями
3. Использовать итеративный подход:
- Первая итерация: общая архитектура + ключевые модели
- Вторая итерация: детализация каждого флоу
- Третья итерация: проверка целостности и связей
4. Проверять покрытие требований:
- Составить чек-лист требований в начале
- Отмечать покрытые требования по мере проектирования
- В конце проверить, что ничего не пропущено
5. Документировать решения и trade-offs:
- Записывать принятые решения и их обоснования
- Отмечать альтернативы, которые были отвергнуты
- Фиксировать известные ограничения
Кандидат продемонстрировал зрелый подход к рефлексии, что является важным навыком для senior-разработчика. Способность критически оценить собственную работу и выявить области для улучшения — это признак профессионального роста.
