Исправление бесконечного рендер-цикла SearchView и поиск по публичному ключу

- SearchViewModel: заменён @Observable на ObservableObject + @Published
  (устранён infinite body loop SearchView → 99% CPU фриз после логина)
- SearchView: @State → @StateObject, RecentSection: @ObservedObject
- Добавлен клиентский поиск по публичному ключу (сервер ищет только по нику)
- ChatDetailView: убран @State на DialogRepository singleton
- ChatListView: замена closure на @Binding, убран DispatchQueue.main.async
- MainTabView: убран пустой onChange, замена closure на @Binding
- SettingsViewModel: конвертирован в ObservableObject
- Добавлены debug-принты для отладки рендер-циклов

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 05:14:54 +05:00
parent 6bef51e235
commit e26d94b268
9 changed files with 442 additions and 309 deletions

View File

@@ -1,16 +1,40 @@
import Combine
import SwiftUI
// MARK: - Navigation State (survives parent re-renders)
@MainActor
final class ChatListNavigationState: ObservableObject {
@Published var path: [ChatRoute] = []
}
// MARK: - ChatListView
/// The root chat list screen.
///
/// **IMPORTANT:** This view's `body` must NOT read any `@Observable` singleton
/// (`ProtocolManager`, `DialogRepository`, `AccountManager`, `SessionManager`)
/// directly. Such reads create implicit Observation tracking, causing the
/// NavigationStack to rebuild on every property change (e.g. during handshake)
/// and triggering "Update NavigationRequestObserver tried to update multiple
/// times per frame" app freeze.
///
/// All `@Observable` access is isolated in dedicated child views:
/// - `DeviceVerificationBannersContainer` `ProtocolManager`
/// - `ToolbarStoriesAvatar` `AccountManager` / `SessionManager`
/// - `ChatListDialogContent` `DialogRepository` (via ViewModel)
struct ChatListView: View {
@Binding var isSearchActive: Bool
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil
@Binding var isDetailPresented: Bool
@StateObject private var viewModel = ChatListViewModel()
@StateObject private var navigationState = ChatListNavigationState()
@State private var searchText = ""
@State private var navigationPath: [ChatRoute] = []
@MainActor static var _bodyCount = 0
var body: some View {
NavigationStack(path: $navigationPath) {
let _ = Self._bodyCount += 1
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
NavigationStack(path: $navigationState.path) {
ZStack {
RosettaColors.Adaptive.background
.ignoresSafeArea()
@@ -23,7 +47,7 @@ struct ChatListView: View {
onOpenDialog: { route in
isSearchActive = false
searchText = ""
navigationPath.append(route)
navigationState.path.append(route)
}
)
} else {
@@ -46,16 +70,16 @@ struct ChatListView: View {
.navigationDestination(for: ChatRoute.self) { route in
ChatDetailView(
route: route,
onPresentedChange: { isPresented in
onChatDetailVisibilityChange?(isPresented)
onPresentedChange: { presented in
isDetailPresented = presented
}
)
}
.onAppear {
onChatDetailVisibilityChange?(!navigationPath.isEmpty)
isDetailPresented = !navigationState.path.isEmpty
}
.onChange(of: navigationPath) { _, newPath in
onChatDetailVisibilityChange?(!newPath.isEmpty)
.onChange(of: navigationState.path) { _, newPath in
isDetailPresented = !newPath.isEmpty
}
}
.tint(RosettaColors.figmaBlue)
@@ -68,36 +92,129 @@ private extension ChatListView {
@ViewBuilder
var normalContent: some View {
VStack(spacing: 0) {
deviceVerificationBanners
// Isolated view reads ProtocolManager (@Observable) without
// polluting ChatListView's observation scope.
DeviceVerificationBannersContainer()
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
}
}
}
@ViewBuilder
var deviceVerificationBanners: some View {
let protocol_ = ProtocolManager.shared
// Banner 1: THIS device needs approval from another device
if protocol_.connectionState == .deviceVerificationRequired {
DeviceWaitingApprovalBanner()
}
// Banner 2: ANOTHER device needs approval from THIS device
if let pendingDevice = protocol_.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { protocol_.acceptDevice(pendingDevice.deviceId) },
onDecline: { protocol_.declineDevice(pendingDevice.deviceId) }
// Isolated view reads DialogRepository (@Observable) via viewModel
// without polluting ChatListView's observation scope.
ChatListDialogContent(
viewModel: viewModel,
navigationState: navigationState
)
}
}
}
var dialogList: some View {
// MARK: - Toolbar
private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
// Isolated view reads AccountManager & SessionManager (@Observable)
// without polluting ChatListView's observation scope.
ToolbarStoriesAvatar()
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
HStack(spacing: 8) {
Button { } label: {
Image(systemName: "camera")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button { } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.padding(.bottom, 2)
.accessibilityLabel("New chat")
}
}
}
}
// MARK: - Toolbar Stories Avatar (observation-isolated)
/// Reads `AccountManager` and `SessionManager` in its own observation scope.
/// Changes to these `@Observable` singletons only re-render this small view,
/// not the parent ChatListView / NavigationStack.
private struct ToolbarStoriesAvatar: View {
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟣 ToolbarStoriesAvatar.body #\(Self._bodyCount)")
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
}
}
// MARK: - Device Verification Banners (observation-isolated)
/// Reads `ProtocolManager` in its own observation scope.
/// During handshake, `connectionState` changes 4+ times rapidly this view
/// absorbs those re-renders instead of cascading them to the NavigationStack.
private struct DeviceVerificationBannersContainer: View {
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("⚪ DeviceVerificationBanners.body #\(Self._bodyCount)")
let proto = ProtocolManager.shared
if proto.connectionState == .deviceVerificationRequired {
DeviceWaitingApprovalBanner()
}
if let pendingDevice = proto.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
)
}
}
}
// MARK: - Dialog Content (observation-isolated)
/// Reads `DialogRepository` (via ViewModel) in its own observation scope.
/// Changes to dialogs only re-render this list, not the NavigationStack.
private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
}
}
private var dialogList: some View {
List {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
@@ -128,9 +245,9 @@ private extension ChatListView {
.scrollDismissesKeyboard(.immediately)
}
func chatRow(_ dialog: Dialog) -> some View {
private func chatRow(_ dialog: Dialog) -> some View {
Button {
navigationPath.append(ChatRoute(dialog: dialog))
navigationState.path.append(ChatRoute(dialog: dialog))
} label: {
ChatRowView(dialog: dialog)
}
@@ -172,58 +289,6 @@ private extension ChatListView {
}
}
// MARK: - Toolbar
private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
storiesAvatars
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
HStack(spacing: 8) {
Button { } label: {
Image(systemName: "camera")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button { } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.padding(.bottom, 2)
.accessibilityLabel("New chat")
}
}
}
@ViewBuilder
private var storiesAvatars: some View {
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
}
}
// MARK: - Device Waiting Approval Banner
/// Shown when THIS device needs approval from another Rosetta device.
@@ -303,4 +368,4 @@ private struct DeviceApprovalBanner: View {
}
}
#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) }
#Preview { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) }