mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
perf(native): optimize iOS transcript refresh and add frame profiling
Preserve hosted rows while propagating current SwiftUI environments and render captures. Add differential refresh regressions, opt-in real-clock iOS/Android profiling, and reproducible performance results. Validation: typecheck; CLI/hub/web/shared/relay suites (Web rerun with NODE_OPTIONS=--no-experimental-webstorage for Node 26/jsdom compatibility); existing iOS Debug/Release and Android regression runs.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import HapiUI
|
||||
|
||||
@MainActor
|
||||
final class TranscriptRefreshTests: XCTestCase {
|
||||
struct Row: Identifiable, Equatable {
|
||||
let id: String
|
||||
var height: CGFloat = 120
|
||||
}
|
||||
|
||||
struct Sample: Equatable {
|
||||
let basePath: String
|
||||
let dark: Bool
|
||||
let size: DynamicTypeSize
|
||||
let locale: String
|
||||
let direction: LayoutDirection
|
||||
}
|
||||
|
||||
@Observable @MainActor
|
||||
final class Driver {
|
||||
var rows = (0..<80).map { Row(id: String($0)) }
|
||||
var version = 0
|
||||
var basePath = "/before"
|
||||
var dark = false
|
||||
var size = DynamicTypeSize.large
|
||||
var locale = Locale(identifier: "en")
|
||||
var direction = LayoutDirection.leftToRight
|
||||
var actionToken = 0
|
||||
@ObservationIgnored var lastAction = -1
|
||||
@ObservationIgnored var layouts: [Int] = []
|
||||
@ObservationIgnored var samples: [String: Sample] = [:]
|
||||
@ObservationIgnored var actions: [String: HapiOpenURLAction] = [:]
|
||||
}
|
||||
|
||||
private struct Harness: View {
|
||||
let driver: Driver
|
||||
var body: some View {
|
||||
let token = driver.actionToken
|
||||
ListView(driver: driver)
|
||||
.hapiTheme(driver.dark ? .dark : .light)
|
||||
.environment(\.dynamicTypeSize, driver.size)
|
||||
.environment(\.locale, driver.locale)
|
||||
.environment(\.layoutDirection, driver.direction)
|
||||
.environment(\.hapiOpenURL, HapiOpenURLAction { _ in
|
||||
Task { @MainActor in driver.lastAction = token }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private struct ListView: View {
|
||||
let driver: Driver
|
||||
var body: some View {
|
||||
// An ordinary value capture, not a live observable read in a row.
|
||||
let basePath = driver.basePath
|
||||
AnchoredTranscriptList(
|
||||
items: driver.rows, historyVersion: driver.version, jumpToken: 0,
|
||||
historyControlID: "history", onViewport: { _ in },
|
||||
onLayout: { version, _ in driver.layouts.append(version) }
|
||||
) { row in
|
||||
AnyView(ProbeRow(row: row, basePath: basePath, driver: driver))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProbeRow: View {
|
||||
let row: Row
|
||||
let basePath: String
|
||||
let driver: Driver
|
||||
@Environment(\.dynamicTypeSize) private var size
|
||||
var body: some View {
|
||||
Text(row.id)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: row.height + (size.isAccessibilitySize ? 60 : 0))
|
||||
.background(Probe(id: row.id, basePath: basePath, driver: driver))
|
||||
}
|
||||
}
|
||||
|
||||
private struct Probe: UIViewRepresentable {
|
||||
let id: String
|
||||
let basePath: String
|
||||
let driver: Driver
|
||||
func makeUIView(context: Context) -> UIView { UIView() }
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
let environment = context.environment
|
||||
driver.samples[id] = Sample(basePath: basePath, dark: environment.hapiTheme.isDark,
|
||||
size: environment.dynamicTypeSize, locale: environment.locale.identifier,
|
||||
direction: environment.layoutDirection)
|
||||
driver.actions[id] = environment.hapiOpenURL
|
||||
}
|
||||
}
|
||||
|
||||
private func host(_ driver: Driver) throws -> (UIWindow, TranscriptCollectionController<Row>, UICollectionView) {
|
||||
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let window = UIWindow(windowScene: scene)
|
||||
let host = UIHostingController(rootView: Harness(driver: driver))
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
host.view.layoutIfNeeded()
|
||||
func find(_ controller: UIViewController) -> TranscriptCollectionController<Row>? {
|
||||
if let result = controller as? TranscriptCollectionController<Row> { return result }
|
||||
return controller.children.lazy.compactMap(find).first
|
||||
}
|
||||
let controller = try XCTUnwrap(find(host))
|
||||
return (window, controller, try XCTUnwrap(controller.view as? UICollectionView))
|
||||
}
|
||||
|
||||
private func settle(_ condition: () -> Bool = { true }, file: StaticString = #filePath, line: UInt = #line) async {
|
||||
for _ in 0..<100 {
|
||||
try? await Task.sleep(for: .milliseconds(20))
|
||||
if condition() {
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
return
|
||||
}
|
||||
}
|
||||
XCTFail("Transcript did not settle", file: file, line: line)
|
||||
}
|
||||
|
||||
private func browse(_ view: UICollectionView, index: Int) async {
|
||||
view.delegate?.scrollViewWillBeginDragging?(view)
|
||||
view.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
|
||||
await settle()
|
||||
}
|
||||
|
||||
func testOffscreenUpdatesKeepVisibleHostingConfigurationsAndStillResizeChangedRows() async throws {
|
||||
let driver = Driver()
|
||||
let (window, controller, view) = try host(driver)
|
||||
defer { window.isHidden = true }
|
||||
await settle { driver.layouts.contains(0) }
|
||||
await browse(view, index: 25)
|
||||
let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0)))
|
||||
let offset = cell.frame.minY - view.contentOffset.y
|
||||
#if DEBUG
|
||||
let configurations = controller.cellConfigurationCount
|
||||
#endif
|
||||
for version in 1...3 {
|
||||
driver.rows[79].height += 20
|
||||
driver.version = version
|
||||
await settle { driver.layouts.contains(version) }
|
||||
}
|
||||
#if DEBUG
|
||||
XCTAssertEqual(controller.cellConfigurationCount, configurations,
|
||||
"Offscreen streaming must not reinstall unchanged visible hosting roots")
|
||||
#endif
|
||||
XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1)
|
||||
driver.rows[25].height += 70
|
||||
await settle { abs(cell.frame.height - 190) < 1 }
|
||||
XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1)
|
||||
await browse(view, index: 79)
|
||||
let tail = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 79, section: 0)))
|
||||
XCTAssertEqual(tail.frame.height, 180, accuracy: 1, "Recycled cells must use the latest offscreen value")
|
||||
}
|
||||
|
||||
func testEqualItemsReceiveLatestEnvironmentValueCapturesAndActions() async throws {
|
||||
let driver = Driver()
|
||||
let (window, controller, view) = try host(driver)
|
||||
defer { window.isHidden = true }
|
||||
await settle { driver.layouts.contains(0) }
|
||||
await browse(view, index: 25)
|
||||
#if DEBUG
|
||||
let configurations = controller.cellConfigurationCount
|
||||
#endif
|
||||
driver.basePath = "/after"
|
||||
driver.dark = true
|
||||
driver.actionToken = 1
|
||||
let expected = Sample(basePath: "/after", dark: true, size: .large, locale: "en", direction: .leftToRight)
|
||||
await settle { driver.samples["25"] == expected }
|
||||
XCTAssertEqual(driver.samples["25"], expected)
|
||||
#if DEBUG
|
||||
XCTAssertEqual(controller.cellConfigurationCount, configurations)
|
||||
#endif
|
||||
try XCTUnwrap(driver.actions["25"])(URL(string: "hapi-file://?path=test")!)
|
||||
await settle { driver.lastAction == 1 }
|
||||
// UIKit may itself request cells again when layout-direction traits
|
||||
// change; only ordinary data/context updates have a zero-config budget.
|
||||
driver.locale = Locale(identifier: "ar")
|
||||
driver.direction = .rightToLeft
|
||||
driver.size = .accessibility2
|
||||
let recycled = Sample(basePath: "/after", dark: true, size: .accessibility2, locale: "ar", direction: .rightToLeft)
|
||||
await settle { driver.samples["25"] == recycled }
|
||||
let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0)))
|
||||
XCTAssertEqual(cell.frame.height, 180, accuracy: 1, "Environment-only changes must still self-size")
|
||||
await browse(view, index: 60)
|
||||
XCTAssertEqual(driver.samples["60"], recycled)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user