Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// EmbeddedBlockContentProviderFactory.swift
// Mindbox
//
// Created by vailence on 06.08.2026.
// Copyright © 2026 Mindbox. All rights reserved.
//

import Foundation

/// Собирает провайдер контента под конкретный блок.
///
/// Провайдер принадлежит одному контейнеру, поэтому создаётся на каждый блок заново — это и
/// делает блоки с одинаковым id независимыми.
protocol EmbeddedBlockContentProviderMaking {
func makeProvider(id: String) -> EmbeddedBlockWebViewProvider
}

final class EmbeddedBlockContentProviderFactory: EmbeddedBlockContentProviderMaking {

private let resolver: EmbeddedBlockResolving
private let actionHandler: EmbeddedBlockActionHandling

init(resolver: EmbeddedBlockResolving,
actionHandler: EmbeddedBlockActionHandling) {
self.resolver = resolver
self.actionHandler = actionHandler
}

func makeProvider(id: String) -> EmbeddedBlockWebViewProvider {
EmbeddedBlockWebViewProvider(id: id,
resolver: resolver,
actionHandler: actionHandler,
makePage: { EmbeddedBlockWebViewPage(content: $0) })
}
}
277 changes: 277 additions & 0 deletions Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
//
// EmbeddedBlockWebViewProvider.swift
// Mindbox
//
// Created by vailence on 03.08.2026.
// Copyright © 2026 Mindbox. All rights reserved.
//

import UIKit
import MindboxLogger

/// Контент встроенного блока — веб-страница, найденная по id блока.
///
/// Провайдер не рисует контент и не знает механик: он спрашивает у резолвера, что стоит за id,
/// переводит core-сообщения страницы в состояния контейнера, а действия сверх core-слоя отдаёт
/// универсальному обработчику.
///
/// Экземпляр принадлежит одному контейнеру, поэтому `start()` и `stop()` просто повторяют его
/// видимость и могут вызываться по кругу. После `stop()` провайдер обязан молчать до следующего
/// `start()` — на это опирается контейнер, когда сворачивает просроченный блок.
final class EmbeddedBlockWebViewProvider {

/// Сообщает каждую смену состояния на главном потоке. Ставится контейнером.
var onStateChange: ((EmbeddedBlockState) -> Void)?

var contentView: UIView? { isReady ? page?.view : nil }

private let id: String
private let resolver: EmbeddedBlockResolving
private let actionHandler: EmbeddedBlockActionHandling
private let readinessOverrides: EmbeddedBlockReadinessOverriding
private let makePage: (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting

/// Страница переживает рестарты: контейнер стартует и останавливает блок по видимости, и
/// пересоздавать вебвью на каждое возвращение в окно незачем.
private var page: EmbeddedBlockPageHosting?

private var isStarted = false

/// Чем кончилась текущая попытка: `nil` — ещё ничем.
///
/// Исход переживает `stop()`: он свойство страницы, а не факта нахождения в окне. Провал и
/// `empty` при этом не убивают страницу — она жива и может продолжать говорить, — поэтому
/// известный исход нужен и как признак того, что блока на экране больше нет.
private var outcome: EmbeddedBlockState?

private var isReady: Bool { outcome == .ready }

/// Номер текущей попытки загрузки. Резолв может ответить уже после `stop()` или после
/// перезагрузки — по номеру видно, что ответ относится к прошлой попытке, и его надо выбросить.
private var loadGeneration = 0

init(id: String,
resolver: EmbeddedBlockResolving,
actionHandler: EmbeddedBlockActionHandling,
readinessOverrides: EmbeddedBlockReadinessOverriding = EmbeddedBlockReadinessOverrides.shared,
makePage: @escaping (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting) {
self.id = id
self.resolver = resolver
self.actionHandler = actionHandler
self.readinessOverrides = readinessOverrides
self.makePage = makePage

EmbeddedBlockWebViewProvider.blockCreated(id: id)
}

deinit {
EmbeddedBlockWebViewProvider.blockReleased(id: id)
}

func start() {
start(forceRefresh: false)
}

func stop() {
guard isStarted else { return }

isStarted = false
// Исход не сбрасываем: он свойство страницы, а не факта нахождения в окне. Иначе каждый
// проход блока по экрану стоил бы полной перезагрузки.
loadGeneration += 1
page?.cancel()
}

/// Начинает загрузку с нуля: страница выбрасывается, а адрес запрашивается заново в обход кэша
/// резолвера — иначе переехавший или выключенный блок вечно доставал бы прежний адрес.
func reload() {
Logger.common(message: "[EmbeddedBlock] Block '\(id)' is reloading", category: .embeddedBlocks)

// Прежняя страница больше не имеет отношения к делу — сначала отключаем её от себя, чтобы
// её запоздавшие сообщения не попали в новую попытку.
page?.onMessage = nil
page?.onLoadFailure = nil
page?.onLoadFinish = nil
page?.cancel()
page = nil

isStarted = false
outcome = nil
loadGeneration += 1

start(forceRefresh: true)
}

func handle(_ message: EmbeddedBlockPageMessage) {
guard isStarted else { return }

switch message {
case .ready(let height):
apply(height: height)
case .heightChanged(let height):
Comment thread
justSmK marked this conversation as resolved.
// Высотой владеет хост — сообщение остаётся в контракте страницы, но на нативной
// стороне ни на что не влияет.
Logger.common(message: "[EmbeddedBlock] Ignored heightChanged(\(height)): the host owns the container height", category: .embeddedBlocks)
case .empty:
outcome = .empty
onStateChange?(.empty)
case .action(let action):
// Блока на экране нет, а страница жива и продолжает работать — например, досылает то,
// что запланировал её `setTimeout`. Выполнять её действия в этот момент нельзя: за
// невидимым блоком не стоит ни одного касания пользователя, а `openUrl` увёл бы его из
// приложения на пустом месте.
guard isShown else {
Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored action '\(action.type)' from a block that is not shown",
category: .embeddedBlocks)
return
}

actionHandler.handle(action)
}
}

func handleLoadFailure() {
guard isStarted else { return }

outcome = .failed
onStateChange?(.failed)
}

/// Пока исхода нет, страница ещё грузится — её сообщения относятся к живому блоку.
private var isShown: Bool {
outcome == nil || outcome == .ready
}

/// Загруженный документ сам по себе ничего не значит: показать блок по нему разрешает только
/// отладочная подмена — для страниц, которые ещё не умеют присылать `ready`.
func handleLoadFinish() {
guard isStarted, !isReady, readinessOverrides.treatsLoadedPageAsReady else { return }

Logger.common(message: "[EmbeddedBlock] Block '\(id)': debug readiness is ON, showing the loaded page without a 'ready' from it",
level: .default,
category: .embeddedBlocks)
outcome = .ready
onStateChange?(.ready)
}

private func start(forceRefresh: Bool) {
guard !isStarted else { return }

isStarted = true

// Страница уже отрендерилась и никуда не делась — показываем её как есть. Возврат блока
// в окно не стоит ни сети, ни шиммера, ни повторных событий хосту.
if isReady, page != nil {
Logger.common(message: "[EmbeddedBlock] Block '\(id)': showing the page rendered earlier",
category: .embeddedBlocks)
onStateChange?(.ready)
return
}

onStateChange?(.loading)
// Началась новая попытка: чем кончилась прошлая, больше не важно — в том числе и для того,
// выполнять ли действия страницы.
outcome = nil

if let page {
page.load()
return
}

let generation = loadGeneration
resolver.resolve(id, forceRefresh: forceRefresh) { [weak self] resolution in
Comment thread
justSmK marked this conversation as resolved.
guard let self, self.isStarted, self.loadGeneration == generation else { return }

switch resolution {
case .empty:
Logger.common(message: "[EmbeddedBlock] Block id '\(self.id)' resolved as empty",
category: .embeddedBlocks)
self.onStateChange?(.empty)
case .content(let content):
Comment thread
Vailence marked this conversation as resolved.
let page = self.makePage(content)
page.onMessage = { [weak self] message in
self?.handle(message)
}
page.onLoadFailure = { [weak self] in
self?.handleLoadFailure()
}
page.onLoadFinish = { [weak self] in
self?.handleLoadFinish()
}
self.page = page
page.load()
}
}
}

private func apply(height: CGFloat) {
// «Показывать нечего» страница сообщает явным `empty`, поэтому нулевая высота — это
// сломанная вёрстка, то есть ошибка.
guard height > 0 else {
Comment thread
justSmK marked this conversation as resolved.
Logger.common(message: "[EmbeddedBlock] Block '\(id)': page reported zero height, treating as broken", category: .embeddedBlocks)
outcome = .empty
Comment thread
justSmK marked this conversation as resolved.
onStateChange?(.failed)
return
}

Logger.common(message: "[EmbeddedBlock] Block '\(id)': page is ready", category: .embeddedBlocks)
outcome = .ready
onStateChange?(.ready)
}
}

// MARK: - Live blocks

/// Сколько блоков с каждым id живо прямо сейчас.
///
/// Диагностика, а не механика: два блока с одним id — законный случай, оба покажут один и тот же
/// контент. Но чаще это либо скопированный id, либо переиспользованная ячейка, в которую попал
/// контейнер от другой строки, — а у обоих случаев нет заметных симптомов, кроме «блок оказался не
/// там, где ждали». Поэтому SDK говорит об этом в лог.
///
/// Счётчик общий на процесс, потому что вопрос тоже общий: одинаковые id ищутся не внутри блока, а
/// между блоками. Живых блоков он не удерживает — хранит только числа.
extension EmbeddedBlockWebViewProvider {

private static var liveBlocks: [String: Int] = [:]

/// Блоки создаются и умирают с UIKit-вью, то есть на главном потоке. Замок стоит на случай, если
/// это когда-нибудь перестанет быть правдой: диагностика не должна ронять SDK.
private static let liveBlocksLock = NSLock()

static func liveCount(for id: String) -> Int {
liveBlocksLock.lock()
defer { liveBlocksLock.unlock() }

return liveBlocks[id] ?? 0
}

fileprivate static func blockCreated(id: String) {
liveBlocksLock.lock()
let count = (liveBlocks[id] ?? 0) + 1
liveBlocks[id] = count
liveBlocksLock.unlock()

guard count > 1 else { return }

Logger.common(message: """
[EmbeddedBlock] \(count) live blocks share id '\(id)'. They show the same content, \
each rendered on its own. If that is unexpected, check that a reusable cell is not carrying \
a block container from another row: a block is created for one id and cannot be repointed.
""", category: .embeddedBlocks)
}

fileprivate static func blockReleased(id: String) {
liveBlocksLock.lock()
let remaining = max(0, (liveBlocks[id] ?? 1) - 1)
if remaining > 0 {
liveBlocks[id] = remaining
} else {
liveBlocks.removeValue(forKey: id)
}
liveBlocksLock.unlock()

Logger.common(message: "[EmbeddedBlock] Block '\(id)' is released, \(remaining) live with this id",
category: .embeddedBlocks)
}
}
4 changes: 3 additions & 1 deletion MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
// Copyright © 2026 Mindbox. All rights reserved.
//

import Foundation
import UIKit
@testable import Mindbox

extension EmbeddedBlockWebContent {

static let stub = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/block.html")!)

static let other = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/another-block.html")!)
}
48 changes: 48 additions & 0 deletions MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,52 @@ private final class ContentLoaderSpy {
completions = []
pending.forEach { $0(resolution) }
}

/// Отвечает с фоновой очереди — так ответит настоящий конфиг, разобранный не на главном потоке.
func answerOffMain(_ resolution: EmbeddedBlockResolution) {
let pending = completions
completions = []
DispatchQueue.global().async {
pending.forEach { $0(resolution) }
}
}
}

/// На каком потоке резолвер отдал ответ. Отдельный тип вместо `Bool` — чтобы упавший тест сразу
/// говорил, что именно разъехалось.
private enum DeliveryThread {
case main
case other
}

/// Ждёт ответов резолвера и запоминает, на каком потоке каждый пришёл.
///
/// Читают и пишут его только с главного потока — если это перестанет быть правдой, тест как раз и
/// упадёт на `threads`.
private final class DeliveryRecorder {

private(set) var answers: [EmbeddedBlockResolution] = []
private(set) var threads: [DeliveryThread] = []

private var expectedCount = 0
private var continuation: CheckedContinuation<Void, Never>?

func record(_ resolution: EmbeddedBlockResolution) {
answers.append(resolution)
threads.append(Thread.isMainThread ? .main : .other)

guard answers.count >= expectedCount, let continuation else { return }
self.continuation = nil
continuation.resume()
}

/// Загрузку запускает сам ожидающий: начни её раньше — и ответ мог бы приехать до того, как
/// тест встал ждать, а ожидание повисло бы навсегда.
func waitForAnswers(count: Int, _ startLoading: () -> Void) async {
expectedCount = count
await withCheckedContinuation { continuation in
self.continuation = continuation
startLoading()
}
}
}