From 9ad89067fd4d8be14ef6cab86ab2e5897ae255a5 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 09:45:03 -0800 Subject: [PATCH 1/2] Let ImageIcon and GroundImageState take an image URL ImageIcon(imageUrl:) and GroundImageState(bounds:imageUrl:) load the image themselves through one shared loader: each URL is fetched once, finished images are cached, failures are remembered and logged once per URL. An icon is a transparent image of the same size and anchor until its image arrives. Its hashCode mixes in the load state (pending / ready / failed), so it changes exactly once; MarkerState then re-assigns the same icon, which redraws through the usual icon-change path (StateMutationSignal and $icon). A marker whose icon was replaced in the meantime is left alone. A ground image shows a 1x1 transparent image until loaded, then the image is assigned to `image`. Changing imageUrl keeps the current image until the new one arrives and drops stale results; assigning `image` clears imageUrl. The generated id comes from the URL so it is stable across the load. API: additions only in behaviour. ImageIcon.init(image:) becomes a convenience initializer of the final class (same signature) so both inits share one designated initializer. --- Sources/MapConductorCore/MapLayerItems.swift | 23 ++ .../groundimage/GroundImageState.swift | 110 ++++++++- .../image/RemoteImageLoader.swift | 108 +++++++++ .../MapConductorCore/marker/ImageIcon.swift | 214 ++++++++++++++++-- .../MapConductorCore/marker/MarkerState.swift | 23 +- .../ImageUrlLoadingTests.swift | 177 +++++++++++++++ api/MapConductorCore.api.swift | 18 +- docs/GroundImageState.swift.md | 25 ++ docs/ImageIcon.swift.md | 29 ++- 9 files changed, 705 insertions(+), 22 deletions(-) create mode 100644 Sources/MapConductorCore/image/RemoteImageLoader.swift create mode 100644 Tests/MapConductorCoreTests/ImageUrlLoadingTests.swift diff --git a/Sources/MapConductorCore/MapLayerItems.swift b/Sources/MapConductorCore/MapLayerItems.swift index 545c1dc..80df610 100644 --- a/Sources/MapConductorCore/MapLayerItems.swift +++ b/Sources/MapConductorCore/MapLayerItems.swift @@ -35,6 +35,29 @@ public struct GroundImage: MapOverlayItemProtocol, Identifiable { self.id = state.id } + /// 画像を URL で渡す(``GroundImageState/init(bounds:imageUrl:opacity:tileSize:id:extra:onClick:)``)。 + public init( + bounds: GeoRectBounds, + imageUrl: URL, + opacity: Double = 0.5, + tileSize: Int = 512, + id: String? = nil, + extra: Any? = nil, + onClick: OnGroundImageEventHandler? = nil + ) { + let state = GroundImageState( + bounds: bounds, + imageUrl: imageUrl, + opacity: opacity, + tileSize: tileSize, + id: id, + extra: extra, + onClick: onClick + ) + self.state = state + self.id = state.id + } + public func append(to content: inout MapViewContent) { content.groundImages.append(self) } diff --git a/Sources/MapConductorCore/groundimage/GroundImageState.swift b/Sources/MapConductorCore/groundimage/GroundImageState.swift index c985a82..a6830c9 100644 --- a/Sources/MapConductorCore/groundimage/GroundImageState.swift +++ b/Sources/MapConductorCore/groundimage/GroundImageState.swift @@ -24,6 +24,18 @@ public struct GroundImageEvent { public typealias OnGroundImageEventHandler = (GroundImageEvent) -> Void +/// 地表画像。 +/// +/// ## URL で渡す +/// +/// `GroundImageState(bounds:imageUrl:)` は画像を自分で読み込む(``ImageIcon`` の `imageUrl` と +/// 同じローダー)。読み終えるまで `image` は 1×1 の透明な画像で、読み終えたら `image` に入る +/// (ふだんの `image` の差し替えと同じく描き直される)。 +/// +/// - `imageUrl` を別の URL にすると、読み終えるまでは今の画像のまま、読み終えたら差し替わる。 +/// 読み終える前にもう一度変えたら、古い方の結果は捨てる。 +/// - `image` を自分で入れると `imageUrl` は nil に戻る(URL の読み込みより後の指定が勝つ)。 +/// - 失敗したときは画像を変えない(ログに URL ごと 1 度だけ出す)。 public final class GroundImageState: ObservableObject, Identifiable, Equatable, Hashable { public let id: String @@ -32,12 +44,26 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, public let mutations = StateMutationSignal() @Published public var bounds: GeoRectBounds { didSet { mutations.notifyMutated() } } - @Published public var image: UIImage { didSet { mutations.notifyMutated() } } + @Published public var image: UIImage { + didSet { + if !applyingLoadedImage, imageUrl != nil { imageUrl = nil } + mutations.notifyMutated() + } + } + /// 画像を読み込む URL。nil なら `image` をそのまま使う。 + @Published public var imageUrl: URL? { + didSet { + if imageUrl != oldValue { loadImageUrl() } + } + } @Published public var opacity: Double { didSet { mutations.notifyMutated() } } @Published public var tileSize: Int { didSet { mutations.notifyMutated() } } @Published public var extra: Any? { didSet { mutations.notifyMutated() } } @Published public var onClick: OnGroundImageEventHandler? { didSet { mutations.notifyMutated() } } + private var loader: RemoteImageLoader = .shared + private var applyingLoadedImage = false + public init( bounds: GeoRectBounds, image: UIImage, @@ -49,7 +75,7 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, ) { let resolvedId = id ?? GroundImageState.makeGroundImageId( bounds: bounds, - image: image, + imageHash: javaHash(image), opacity: opacity, tileSize: tileSize, extra: extra @@ -63,6 +89,75 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, self.onClick = onClick } + /// 画像を URL で渡す。読み終えるまでは透明。`file:` の URL も使える。 + public convenience init( + bounds: GeoRectBounds, + imageUrl: URL, + opacity: Double = 1.0, + tileSize: Int = 512, + id: String? = nil, + extra: Any? = nil, + onClick: OnGroundImageEventHandler? = nil + ) { + self.init( + bounds: bounds, imageUrl: imageUrl, opacity: opacity, tileSize: tileSize, id: id, extra: extra, + onClick: onClick, loader: .shared + ) + } + + /// テスト用。ローダーを差し替える。 + convenience init( + bounds: GeoRectBounds, + imageUrl: URL, + opacity: Double = 1.0, + tileSize: Int = 512, + id: String? = nil, + extra: Any? = nil, + onClick: OnGroundImageEventHandler? = nil, + loader: RemoteImageLoader + ) { + // 決めた id が読み込みの前後で変わらないよう、画像ではなく URL から作る。 + let resolvedId = id ?? GroundImageState.makeGroundImageId( + bounds: bounds, + imageHash: javaHash(imageUrl.absoluteString), + opacity: opacity, + tileSize: tileSize, + extra: extra + ) + self.init( + bounds: bounds, image: GroundImageState.placeholder, opacity: opacity, tileSize: tileSize, + id: resolvedId, extra: extra, onClick: onClick + ) + self.loader = loader + self.imageUrl = imageUrl + } + + /// 読み込み中の画像。 + private static let placeholder: UIImage = { + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = false + return UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1), format: format).image { _ in } + }() + + private func loadImageUrl() { + guard let url = imageUrl else { return } + let apply: (UIImage) -> Void = { [weak self] image in + guard let self, self.imageUrl == url else { return } + self.applyingLoadedImage = true + self.image = image + self.applyingLoadedImage = false + } + if case let .ready(image) = loader.status(url) { + apply(image) + return + } + loader.whenSettled(url) { image in + if let image { apply(image) } + } + } + + /// `image` を変えなければ、`imageUrl` も引き継ぐ(読み込み中なら写しの方でも読み終えたら入る)。 public func copy( bounds: GeoRectBounds? = nil, image: UIImage? = nil, @@ -72,7 +167,7 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, extra: Any? = nil, onClick: OnGroundImageEventHandler? = nil ) -> GroundImageState { - GroundImageState( + let copied = GroundImageState( bounds: bounds ?? self.bounds, image: image ?? self.image, opacity: opacity ?? self.opacity, @@ -81,6 +176,11 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, extra: extra ?? self.extra, onClick: onClick ?? self.onClick ) + if image == nil, let imageUrl { + copied.loader = loader + copied.imageUrl = imageUrl + } + return copied } public func fingerPrint() -> GroundImageFingerPrint { @@ -141,14 +241,14 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, private static func makeGroundImageId( bounds: GeoRectBounds, - image: UIImage, + imageHash: Int, opacity: Double, tileSize: Int, extra: Any? ) -> String { let hashCodes = [ javaHash(bounds), - javaHash(image), + imageHash, javaHash(opacity), javaHash(tileSize), javaHash(extra) diff --git a/Sources/MapConductorCore/image/RemoteImageLoader.swift b/Sources/MapConductorCore/image/RemoteImageLoader.swift new file mode 100644 index 0000000..996b4b4 --- /dev/null +++ b/Sources/MapConductorCore/image/RemoteImageLoader.swift @@ -0,0 +1,108 @@ +import Foundation +import UIKit + +/// URL で渡された画像(``ImageIcon/init(imageUrl:iconSize:scale:anchor:infoAnchor:debug:label:labelTextColor:labelTextSize:labelTypeFace:labelStrokeColor:labelPosition:labelWritingMode:)``、 +/// ``GroundImageState/init(bounds:imageUrl:opacity:tileSize:id:extra:onClick:)``)を読み込む。 +/// +/// - 同じ URL は 1 度しか取りに行かない。読み込み中に何度頼まれても、終わったときに +/// 全員へ知らせる。 +/// - 読み終えた画像は `NSCache` に置く。追い出されても、すでに受け取ったアイコンや +/// 地表画像は自分で画像を持っているので困らない。後から作ったものが取り直すだけ。 +/// - 失敗も覚えておき、同じ URL で何度も取りに行かない。ログは URL ごとに 1 度だけ。 +/// - 知らせ(`whenSettled` に渡したクロージャ)はメインスレッドで、1 度だけ呼ぶ。 +/// +/// android-sdk / js-sdk-core にも同じ役割のローダーがある。 +final class RemoteImageLoader { + enum Status { + case pending + case ready(UIImage) + case failed + } + + /// 取りに行く処理。テストで差し替える。結果は任意のスレッドで返してよい。 + typealias Fetch = (URL, @escaping (Data?) -> Void) -> Void + + static let shared = RemoteImageLoader() + + private let fetch: Fetch + private let lock = NSLock() + private let images = NSCache() + private var failed = Set() + private var waiting: [URL: [(UIImage?) -> Void]] = [:] + + init(fetch: @escaping Fetch = RemoteImageLoader.urlSessionFetch) { + self.fetch = fetch + images.countLimit = 256 + } + + /// 今の状態。まだ取りに行っていなければ取りに行く。 + func status(_ url: URL) -> Status { + lock.lock() + if let image = images.object(forKey: url as NSURL) { + lock.unlock() + return .ready(image) + } + if failed.contains(url) { + lock.unlock() + return .failed + } + let start = waiting[url] == nil + if start { waiting[url] = [] } + lock.unlock() + if start { begin(url) } + return .pending + } + + /// 読み込みが終わったら `callback` を呼ぶ(失敗なら nil)。 + /// + /// すでに終わっていれば何もせず false を返す(呼び出し側はそのときの `status` を使う)。 + /// 状態を確かめてから登録するまでの間に終わることはない(同じ鍵の中で行う)。 + @discardableResult + func whenSettled(_ url: URL, _ callback: @escaping (UIImage?) -> Void) -> Bool { + lock.lock() + if images.object(forKey: url as NSURL) != nil || failed.contains(url) { + lock.unlock() + return false + } + let start = waiting[url] == nil + waiting[url, default: []].append(callback) + lock.unlock() + if start { begin(url) } + return true + } + + private func begin(_ url: URL) { + fetch(url) { [weak self] data in + let image = data.flatMap { UIImage(data: $0) } + self?.settle(url, image: image) + } + } + + private func settle(_ url: URL, image: UIImage?) { + lock.lock() + if let image { + images.setObject(image, forKey: url as NSURL) + } else { + failed.insert(url) + } + let callbacks = waiting.removeValue(forKey: url) ?? [] + lock.unlock() + if image == nil { + NSLog("[MapConductor] Failed to load image: %@", url.absoluteString) + } + let deliver = { + for callback in callbacks { callback(image) } + } + if Thread.isMainThread { deliver() } else { DispatchQueue.main.async(execute: deliver) } + } + + static func urlSessionFetch(_ url: URL, _ completion: @escaping (Data?) -> Void) { + URLSession.shared.dataTask(with: url) { data, response, _ in + if let http = response as? HTTPURLResponse, !(200 ..< 300).contains(http.statusCode) { + completion(nil) + return + } + completion(data) + }.resume() + } +} diff --git a/Sources/MapConductorCore/marker/ImageIcon.swift b/Sources/MapConductorCore/marker/ImageIcon.swift index f14532f..7cb573a 100644 --- a/Sources/MapConductorCore/marker/ImageIcon.swift +++ b/Sources/MapConductorCore/marker/ImageIcon.swift @@ -22,6 +22,16 @@ import UIKit /// /// 画像は使われたときに初めて作る。タイル描画は本体の画像と文字を別々に使うので、 /// ラベル付きのアイコンをいくつ作っても、本体の画像は元の画像ごとに 1 枚で済む。 +/// +/// ## URL で渡す +/// +/// `ImageIcon(imageUrl:)` は画像を自分で読み込む(``GroundImageState`` の `imageUrl` と同じ +/// ローダーを使い、同じ URL は 1 度しか取りに行かない)。読み終えるまでは同じ大きさ・アンカーの +/// 透明な画像を返し、読み終えたら、このアイコンを持っているマーカーが描き直される。 +/// 失敗したときは透明のまま(ログに URL ごと 1 度だけ出す)。 +/// +/// `hashCode()` は URL と読み込みの状態(読み込み中・完了・失敗)から作る。状態は 1 度だけ +/// 変わり、そのときマーカーの描き直しの判定に掛かる。 public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { public static let defaultIconSize: CGFloat = DefaultMarkerIcon.defaultIconSize public static let defaultAnchor: CGPoint = CGPoint(x: 0.5, y: 0.5) @@ -41,7 +51,10 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { public let labelPosition: String public let labelWritingMode: String - private let image: UIImage + /// `ImageIcon(imageUrl:)` で作ったときの URL。`ImageIcon(image:)` なら nil。 + public let imageUrl: URL? + + private let source: Source private let requestedAnchor: CGPoint private let requestedInfoAnchor: CGPoint private let layout: DefaultIconLayout @@ -50,10 +63,10 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { private let outlineStrokeWidth: CGFloat private let labelSize: CGSize private let vertical: Bool - private let body: LazyBox - private let full: LazyBox + private let body: ByReadiness + private let full: ByReadiness - public init( + public convenience init( image: UIImage, iconSize: CGFloat = defaultIconSize, scale: CGFloat = 1.0, @@ -68,7 +81,67 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { labelPosition: String = ColorDefaultIcon.defaultLabelPosition, labelWritingMode: String = ColorDefaultIcon.defaultLabelWritingMode ) { - self.image = image + self.init( + source: .image(image), iconSize: iconSize, scale: scale, anchor: anchor, infoAnchor: infoAnchor, + debug: debug, label: label, labelTextColor: labelTextColor, labelTextSize: labelTextSize, + labelTypeFace: labelTypeFace, labelStrokeColor: labelStrokeColor, labelPosition: labelPosition, + labelWritingMode: labelWritingMode + ) + } + + /// 画像を URL で渡す。読み終えるまでは透明。読み終えたら、このアイコンを持つマーカーが + /// 描き直される。`file:` の URL も使える。 + public convenience init( + imageUrl: URL, + iconSize: CGFloat = defaultIconSize, + scale: CGFloat = 1.0, + anchor: CGPoint = defaultAnchor, + infoAnchor: CGPoint = defaultInfoAnchor, + debug: Bool = false, + label: String? = nil, + labelTextColor: UIColor? = ColorDefaultIcon.defaultLabelTextColor, + labelTextSize: CGFloat = ColorDefaultIcon.defaultLabelTextSize, + labelTypeFace: UIFont = ColorDefaultIcon.defaultLabelTypeFace, + labelStrokeColor: UIColor = ColorDefaultIcon.defaultLabelStrokeColor, + labelPosition: String = ColorDefaultIcon.defaultLabelPosition, + labelWritingMode: String = ColorDefaultIcon.defaultLabelWritingMode + ) { + self.init( + source: .url(LoadedImage(url: imageUrl, loader: .shared)), iconSize: iconSize, scale: scale, + anchor: anchor, infoAnchor: infoAnchor, debug: debug, label: label, labelTextColor: labelTextColor, + labelTextSize: labelTextSize, labelTypeFace: labelTypeFace, labelStrokeColor: labelStrokeColor, + labelPosition: labelPosition, labelWritingMode: labelWritingMode + ) + } + + /// テスト用。ローダーを差し替える。 + convenience init(imageUrl: URL, iconSize: CGFloat = defaultIconSize, loader: RemoteImageLoader) { + self.init( + source: .url(LoadedImage(url: imageUrl, loader: loader)), iconSize: iconSize, scale: 1.0, + anchor: ImageIcon.defaultAnchor, infoAnchor: ImageIcon.defaultInfoAnchor, debug: false, label: nil, + labelTextColor: ColorDefaultIcon.defaultLabelTextColor, labelTextSize: ColorDefaultIcon.defaultLabelTextSize, + labelTypeFace: ColorDefaultIcon.defaultLabelTypeFace, labelStrokeColor: ColorDefaultIcon.defaultLabelStrokeColor, + labelPosition: ColorDefaultIcon.defaultLabelPosition, labelWritingMode: ColorDefaultIcon.defaultLabelWritingMode + ) + } + + private init( + source: Source, + iconSize: CGFloat, + scale: CGFloat, + anchor: CGPoint, + infoAnchor: CGPoint, + debug: Bool, + label: String?, + labelTextColor: UIColor?, + labelTextSize: CGFloat, + labelTypeFace: UIFont, + labelStrokeColor: UIColor, + labelPosition: String, + labelWritingMode: String + ) { + self.source = source + if case let .url(loaded) = source { self.imageUrl = loaded.url } else { self.imageUrl = nil } self.iconSize = iconSize self.scale = scale self.debug = debug @@ -119,9 +192,11 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { } let pixelScale = self.pixelScale - let body = LazyBox { + let body = ByReadiness { state in BitmapIcon( - bitmap: ImageIcon.bodyImage(image, canvasSize: canvasSize, pixelScale: pixelScale, debug: debug), + bitmap: state.1.map { + ImageIcon.bodyImage($0, canvasSize: canvasSize, pixelScale: pixelScale, debug: debug) + } ?? ImageIcon.blankBody(canvasSize: canvasSize, pixelScale: pixelScale, debug: debug), anchor: anchor, size: CGSize(width: canvasSize, height: canvasSize), scale: scale, @@ -134,8 +209,8 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { let publicAnchor = self.anchor let publicInfoAnchor = self.infoAnchor let textColor = labelTextColor ?? ColorDefaultIcon.defaultLabelTextColor - self.full = LazyBox { - let bodyIcon = body.get() + self.full = ByReadiness { state in + let bodyIcon = body.get(state) guard let label, labelSize != .zero else { return bodyIcon } let size = CGSize(width: layout.bitmapWidth, height: layout.bitmapHeight) let format = UIGraphicsImageRendererFormat.default() @@ -170,9 +245,18 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { } } - public func toBitmapIcon() -> BitmapIcon { full.get() } + public func toBitmapIcon() -> BitmapIcon { full.get(source.current()) } - var tileBody: BitmapIcon { body.get() } + var tileBody: BitmapIcon { body.get(source.current()) } + + /// URL の画像を読み込み中なら、読み終えたとき(失敗も含む)にメインスレッドで 1 度だけ + /// `callback` を呼んで true を返す。読み込み中でなければ何もせず false。 + /// + /// ``MarkerState`` がこれで描き直しのきっかけを受け取る。 + func whenImageSettles(_ callback: @escaping () -> Void) -> Bool { + guard case let .url(loaded) = source, loaded.readiness == .pending else { return false } + return loaded.loader.whenSettled(loaded.url) { _ in callback() } + } var tileLabel: MarkerTileLabel? { guard let label, labelSize != .zero else { return nil } @@ -194,7 +278,13 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { var layoutSize: CGSize { CGSize(width: layout.bitmapWidth, height: layout.bitmapHeight) } public func hashCode() -> Int { - var result = image.hashValue + var result: Int + switch source { + case let .image(image): + result = image.hashValue + case let .url(loaded): + result = loaded.url.hashValue &* 31 &+ loaded.readiness.rawValue + } result = result &* 31 &+ scale.hashValue result = result &* 31 &+ requestedAnchor.x.hashValue result = result &* 31 &+ requestedAnchor.y.hashValue @@ -228,7 +318,7 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { labelWritingMode: String? = nil ) -> ImageIcon { ImageIcon( - image: image ?? self.image, + source: image.map { Source.image($0) } ?? source, iconSize: iconSize ?? self.iconSize, scale: scale ?? self.scale, anchor: anchor ?? self.requestedAnchor, @@ -244,8 +334,106 @@ public final class ImageIcon: MarkerIconProtocol, TileLabelledIcon { ) } + // MARK: - 画像の出どころ + + enum Readiness: Int { + case pending = 0 + case ready = 1 + case failed = 2 + } + + private enum Source { + case image(UIImage) + case url(LoadedImage) + + /// 今描く画像。読み込み中・失敗なら nil。 + func current() -> (Readiness, UIImage?) { + switch self { + case let .image(image): return (.ready, image) + case let .url(loaded): return loaded.current() + } + } + } + + /// URL の画像。読み終えたら自分で持つ(ローダーのキャッシュから追い出されても困らない)。 + final class LoadedImage { + let url: URL + let loader: RemoteImageLoader + private let lock = NSLock() + private var image: UIImage? + private var failed = false + + init(url: URL, loader: RemoteImageLoader) { + self.url = url + self.loader = loader + // マーカーより先に登録する。マーカーが知らせを受けたときには、もう画像を持っている。 + loader.whenSettled(url) { [weak self] image in self?.settle(image) } + } + + var readiness: Readiness { current().0 } + + func current() -> (Readiness, UIImage?) { + lock.lock() + if let image { lock.unlock(); return (.ready, image) } + if failed { lock.unlock(); return (.failed, nil) } + lock.unlock() + switch loader.status(url) { + case .pending: + return (.pending, nil) + case let .ready(image): + settle(image) + return (.ready, image) + case .failed: + settle(nil) + return (.failed, nil) + } + } + + private func settle(_ image: UIImage?) { + lock.lock() + defer { lock.unlock() } + if self.image != nil || failed { return } + if let image { self.image = image } else { failed = true } + } + } + + /// 画像の状態ごとに 1 度だけ作って覚える。状態は読み込み中から完了・失敗へ 1 度だけ変わる。 + final class ByReadiness { + private let lock = NSLock() + private var cached: (Readiness, Value)? + private let make: ((Readiness, UIImage?)) -> Value + + init(_ make: @escaping ((Readiness, UIImage?)) -> Value) { + self.make = make + } + + func get(_ state: (Readiness, UIImage?)) -> Value { + lock.lock() + defer { lock.unlock() } + if let cached, cached.0 == state.0 { return cached.1 } + let made = make(state) + cached = (state.0, made) + return made + } + } + // MARK: - 本体の画像 + /// 読み込み中・失敗の本体。同じ大きさの透明な画像。 + private static func blankBody(canvasSize: CGFloat, pixelScale: CGFloat, debug: Bool) -> UIImage { + let size = CGSize(width: canvasSize, height: canvasSize) + let format = UIGraphicsImageRendererFormat.default() + format.scale = pixelScale + format.opaque = false + return UIGraphicsImageRenderer(size: size, format: format).image { context in + if debug { + context.cgContext.setStrokeColor(UIColor.black.cgColor) + context.cgContext.setLineWidth(1.0) + context.cgContext.stroke(CGRect(origin: .zero, size: size)) + } + } + } + /// 元の画像 -> 大きさごとの本体の画像。 /// /// 元の画像は弱参照で、ポインタの同一性で引く。元の画像が解放されれば行ごと消えるので、 diff --git a/Sources/MapConductorCore/marker/MarkerState.swift b/Sources/MapConductorCore/marker/MarkerState.swift index cde7025..eac551c 100644 --- a/Sources/MapConductorCore/marker/MarkerState.swift +++ b/Sources/MapConductorCore/marker/MarkerState.swift @@ -27,7 +27,12 @@ public final class MarkerState: ObservableObject, Identifiable, Equatable, Hasha /// subscribing to each state. See ``StateMutationSignal``. public let mutations = StateMutationSignal() - @Published public var icon: (any MarkerIconProtocol)? { didSet { mutations.notifyMutated() } } + @Published public var icon: (any MarkerIconProtocol)? { + didSet { + watchIconImage() + mutations.notifyMutated() + } + } @Published public var clickable: Bool { didSet { mutations.notifyMutated() } } @Published public var draggable: Bool { didSet { mutations.notifyMutated() } } @Published public var zIndex: Int? { didSet { mutations.notifyMutated() } } @@ -80,6 +85,22 @@ public final class MarkerState: ObservableObject, Identifiable, Equatable, Hasha self.onAnimateEnd = onAnimateEnd self.internalAnimation = animation self.position = position + watchIconImage() + } + + /// アイコンが URL の画像を読み込み中なら、読み終えたときに描き直させる + /// (``ImageIcon/init(imageUrl:iconSize:scale:anchor:infoAnchor:debug:label:labelTextColor:labelTextSize:labelTypeFace:labelStrokeColor:labelPosition:labelWritingMode:)``)。 + /// + /// 同じアイコンを入れ直す。アイコンの `hashCode()` は読み込みの状態で変わるので、 + /// `$icon` を見ているプロバイダも、``StateMutationSignal`` を聞いているコントローラも、 + /// ふだんのアイコンの差し替えと同じ道筋で描き直す。その間に別のアイコンへ + /// 差し替えられていたら何もしない。 + private func watchIconImage() { + guard let pending = icon as? ImageIcon else { return } + _ = pending.whenImageSettles { [weak self, weak pending] in + guard let self, let pending, let current = self.icon as? ImageIcon, current === pending else { return } + self.icon = pending + } } public convenience init( diff --git a/Tests/MapConductorCoreTests/ImageUrlLoadingTests.swift b/Tests/MapConductorCoreTests/ImageUrlLoadingTests.swift new file mode 100644 index 0000000..6c2773d --- /dev/null +++ b/Tests/MapConductorCoreTests/ImageUrlLoadingTests.swift @@ -0,0 +1,177 @@ +// URL で渡した画像(ImageIcon(imageUrl:) / GroundImageState(imageUrl:))。 +// +// - 同じ URL は 1 度しか取りに行かない。 +// - アイコンは読み終えるまで同じ大きさの透明な画像で、読み終えると hashCode が変わる。 +// - マーカーは、自分のアイコンが読み終えたときだけ描き直しを知らせる。その前に別のアイコンへ +// 差し替えられていたら知らせない。 +// - 地表画像は読み終えたら image に入る。後から自分で image を入れたら imageUrl は nil。 +// +// 取りに行く処理は偽物に差し替え、テストから返す。 + +import UIKit +import XCTest +@testable import MapConductorCore + +@MainActor +final class ImageUrlLoadingTests: XCTestCase { + private final class FakeFetch { + var requests: [URL] = [] + var completions: [URL: [(Data?) -> Void]] = [:] + + lazy var loader = RemoteImageLoader { [unowned self] url, completion in + self.requests.append(url) + self.completions[url, default: []].append(completion) + } + + func respond(_ url: URL, _ data: Data?) { + let pending = completions.removeValue(forKey: url) ?? [] + pending.forEach { $0(data) } + } + } + + private let url = URL(string: "https://example.com/icon.png")! + private let otherUrl = URL(string: "https://example.com/other.png")! + + private func png(_ color: UIColor, size: CGFloat = 4) -> Data { + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: size, height: size), format: format).pngData { context in + color.setFill() + context.fill(CGRect(x: 0, y: 0, width: size, height: size)) + } + } + + func testSameUrlIsFetchedOnce() { + let fake = FakeFetch() + let a = ImageIcon(imageUrl: url, loader: fake.loader) + let b = ImageIcon(imageUrl: url, loader: fake.loader) + _ = a.toBitmapIcon() + _ = b.hashCode() + XCTAssertEqual(fake.requests, [url]) + + var calls = 0 + XCTAssertTrue(fake.loader.whenSettled(url) { _ in calls += 1 }) + fake.respond(url, png(.red)) + XCTAssertEqual(calls, 1) + // 読み終えた後は登録せず、状態を見ればよい。 + XCTAssertFalse(fake.loader.whenSettled(url) { _ in calls += 1 }) + XCTAssertEqual(fake.requests, [url]) + } + + func testIconIsBlankUntilLoadedAndHashChangesOnce() { + let fake = FakeFetch() + let icon = ImageIcon(imageUrl: url, iconSize: 20, loader: fake.loader) + let pendingHash = icon.hashCode() + let pending = icon.toBitmapIcon() + XCTAssertEqual(pending.size, CGSize(width: 20, height: 20)) + XCTAssertEqual(icon.imageUrl, url) + + fake.respond(url, png(.red)) + let readyHash = icon.hashCode() + XCTAssertNotEqual(pendingHash, readyHash) + let ready = icon.toBitmapIcon() + XCTAssertEqual(ready.size, pending.size) + XCTAssertFalse(ready.bitmap === pending.bitmap) + XCTAssertEqual(icon.hashCode(), readyHash) + // 同じ URL・同じ設定なら、後から作ったアイコンも同じ値。 + XCTAssertEqual(ImageIcon(imageUrl: url, iconSize: 20, loader: fake.loader).hashCode(), readyHash) + } + + func testFailedLoadStaysBlankAndIsNotRetried() { + let fake = FakeFetch() + let icon = ImageIcon(imageUrl: url, loader: fake.loader) + let pendingHash = icon.hashCode() + fake.respond(url, nil) + XCTAssertNotEqual(icon.hashCode(), pendingHash) + _ = ImageIcon(imageUrl: url, loader: fake.loader).toBitmapIcon() + XCTAssertEqual(fake.requests, [url]) + } + + func testMarkerRedrawsWhenItsIconLoads() { + let fake = FakeFetch() + let icon = ImageIcon(imageUrl: url, loader: fake.loader) + let marker = MarkerState(position: GeoPoint(latitude: 35, longitude: 139), id: "m", icon: icon) + var mutations = 0 + marker.mutations.listen { mutations += 1 } + let before = marker.fingerPrint() + + fake.respond(url, png(.red)) + XCTAssertEqual(mutations, 1) + XCTAssertTrue(marker.icon as? ImageIcon === icon) + XCTAssertNotEqual(marker.fingerPrint(), before) + } + + func testMarkerDoesNotRedrawForAReplacedIcon() { + let fake = FakeFetch() + let marker = MarkerState( + position: GeoPoint(latitude: 35, longitude: 139), + id: "m", + icon: ImageIcon(imageUrl: url, loader: fake.loader) + ) + var mutations = 0 + marker.mutations.listen { mutations += 1 } + marker.icon = ImageIcon(imageUrl: otherUrl, loader: fake.loader) + XCTAssertEqual(mutations, 1) + + fake.respond(url, png(.red)) + XCTAssertEqual(mutations, 1) + fake.respond(otherUrl, png(.blue)) + XCTAssertEqual(mutations, 2) + } + + func testManyMarkersShareOneLoad() { + let fake = FakeFetch() + let icon = ImageIcon(imageUrl: url, loader: fake.loader) + let markers = (0 ..< 1000).map { + MarkerState(position: GeoPoint(latitude: 35, longitude: 139), id: "m\($0)", icon: icon) + } + var mutations = 0 + markers.forEach { $0.mutations.listen { mutations += 1 } } + fake.respond(url, png(.red)) + XCTAssertEqual(fake.requests, [url]) + XCTAssertEqual(mutations, markers.count) + } + + func testGroundImageLoadsIntoImage() { + let fake = FakeFetch() + let bounds = GeoRectBounds( + southWest: GeoPoint(latitude: 35, longitude: 139), + northEast: GeoPoint(latitude: 36, longitude: 140) + ) + let state = GroundImageState(bounds: bounds, imageUrl: url, loader: fake.loader) + XCTAssertEqual(state.image.size, CGSize(width: 1, height: 1)) + let id = state.id + var mutations = 0 + state.mutations.listen { mutations += 1 } + + fake.respond(url, png(.red, size: 8)) + XCTAssertEqual(state.image.size, CGSize(width: 8, height: 8)) + XCTAssertEqual(state.imageUrl, url) + XCTAssertEqual(state.id, id) + XCTAssertEqual(mutations, 1) + + // 自分で入れた画像が勝ち、URL は外れる。 + let own = UIImage(data: png(.green, size: 2))! + state.image = own + XCTAssertNil(state.imageUrl) + XCTAssertTrue(state.image === own) + } + + func testGroundImageIgnoresAStaleUrl() { + let fake = FakeFetch() + let bounds = GeoRectBounds( + southWest: GeoPoint(latitude: 35, longitude: 139), + northEast: GeoPoint(latitude: 36, longitude: 140) + ) + let state = GroundImageState(bounds: bounds, imageUrl: url, loader: fake.loader) + state.imageUrl = otherUrl + fake.respond(url, png(.red, size: 8)) + XCTAssertEqual(state.image.size, CGSize(width: 1, height: 1)) + fake.respond(otherUrl, png(.blue, size: 6)) + XCTAssertEqual(state.image.size, CGSize(width: 6, height: 6)) + XCTAssertEqual(state.imageUrl, otherUrl) + + // 写しは URL を引き継ぐ。 + XCTAssertEqual(state.copy().imageUrl, otherUrl) + } +} diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index a0a5e66..b66fe00 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -71,6 +71,7 @@ public struct GroundImage : MapConductorCore::MapOverlayItemProtocol, Swift::Ide public let state: MapConductorCore::GroundImageState public init(state: MapConductorCore::GroundImageState) public init(bounds: MapConductorCore::GeoRectBounds, image: UIKit::UIImage, opacity: Swift::Double = 0.5, tileSize: Swift::Int = 512, id: Swift::String? = nil, extra: Any? = nil, onClick: MapConductorCore::OnGroundImageEventHandler? = nil) + public init(bounds: MapConductorCore::GeoRectBounds, imageUrl: Foundation::URL, opacity: Swift::Double = 0.5, tileSize: Swift::Int = 512, id: Swift::String? = nil, extra: Any? = nil, onClick: MapConductorCore::OnGroundImageEventHandler? = nil) public func append(to content: inout MapConductorCore::MapViewContent) public typealias ID = Swift::String } @@ -959,6 +960,16 @@ final public class GroundImageState : Combine::ObservableObject, Swift::Identifi @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) set } + @Combine::Published @_projectedValueProperty($imageUrl) final public var imageUrl: Foundation::URL? { + get + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + set + } + final public var $imageUrl: Combine::Published.Combine::Publisher { + get + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + set + } @Combine::Published @_projectedValueProperty($opacity) final public var opacity: Swift::Double { get @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) @@ -1000,6 +1011,7 @@ final public class GroundImageState : Combine::ObservableObject, Swift::Identifi set } public init(bounds: MapConductorCore::GeoRectBounds, image: UIKit::UIImage, opacity: Swift::Double = 1.0, tileSize: Swift::Int = 512, id: Swift::String? = nil, extra: Any? = nil, onClick: MapConductorCore::OnGroundImageEventHandler? = nil) + convenience public init(bounds: MapConductorCore::GeoRectBounds, imageUrl: Foundation::URL, opacity: Swift::Double = 1.0, tileSize: Swift::Int = 512, id: Swift::String? = nil, extra: Any? = nil, onClick: MapConductorCore::OnGroundImageEventHandler? = nil) final public func copy(bounds: MapConductorCore::GeoRectBounds? = nil, image: UIKit::UIImage? = nil, opacity: Swift::Double? = nil, tileSize: Swift::Int? = nil, id: Swift::String? = nil, extra: Any? = nil, onClick: MapConductorCore::OnGroundImageEventHandler? = nil) -> MapConductorCore::GroundImageState final public func fingerPrint() -> MapConductorCore::GroundImageFingerPrint final public func asFlow() -> Combine::AnyPublisher @@ -1701,7 +1713,7 @@ final public class ImageDefaultIcon : MapConductorCore::MarkerIconProtocol { final public func copy(backgroundImage: UIKit::UIImage? = nil, strokeColor: UIKit::UIColor? = nil, strokeWidth: CoreFoundation::CGFloat? = nil, scale: CoreFoundation::CGFloat? = nil, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = nil, labelTextSize: CoreFoundation::CGFloat? = nil, labelTypeFace: UIKit::UIFont? = nil, labelStrokeColor: UIKit::UIColor? = nil, labelPosition: Swift::String? = nil, iconSize: CoreFoundation::CGFloat? = nil, debug: Swift::Bool? = nil) -> MapConductorCore::ImageDefaultIcon @objc deinit } -final public class ImageIcon : MapConductorCore::MarkerIconProtocol { +@_hasMissingDesignatedInitializers final public class ImageIcon : MapConductorCore::MarkerIconProtocol { public static let defaultIconSize: CoreFoundation::CGFloat public static let defaultAnchor: CoreFoundation::CGPoint public static let defaultInfoAnchor: CoreFoundation::CGPoint @@ -1717,7 +1729,9 @@ final public class ImageIcon : MapConductorCore::MarkerIconProtocol { final public let labelStrokeColor: UIKit::UIColor final public let labelPosition: Swift::String final public let labelWritingMode: Swift::String - public init(image: UIKit::UIImage, iconSize: CoreFoundation::CGFloat = defaultIconSize, scale: CoreFoundation::CGFloat = 1.0, anchor: CoreFoundation::CGPoint = defaultAnchor, infoAnchor: CoreFoundation::CGPoint = defaultInfoAnchor, debug: Swift::Bool = false, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = ColorDefaultIcon.defaultLabelTextColor, labelTextSize: CoreFoundation::CGFloat = ColorDefaultIcon.defaultLabelTextSize, labelTypeFace: UIKit::UIFont = ColorDefaultIcon.defaultLabelTypeFace, labelStrokeColor: UIKit::UIColor = ColorDefaultIcon.defaultLabelStrokeColor, labelPosition: Swift::String = ColorDefaultIcon.defaultLabelPosition, labelWritingMode: Swift::String = ColorDefaultIcon.defaultLabelWritingMode) + final public let imageUrl: Foundation::URL? + convenience public init(image: UIKit::UIImage, iconSize: CoreFoundation::CGFloat = defaultIconSize, scale: CoreFoundation::CGFloat = 1.0, anchor: CoreFoundation::CGPoint = defaultAnchor, infoAnchor: CoreFoundation::CGPoint = defaultInfoAnchor, debug: Swift::Bool = false, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = ColorDefaultIcon.defaultLabelTextColor, labelTextSize: CoreFoundation::CGFloat = ColorDefaultIcon.defaultLabelTextSize, labelTypeFace: UIKit::UIFont = ColorDefaultIcon.defaultLabelTypeFace, labelStrokeColor: UIKit::UIColor = ColorDefaultIcon.defaultLabelStrokeColor, labelPosition: Swift::String = ColorDefaultIcon.defaultLabelPosition, labelWritingMode: Swift::String = ColorDefaultIcon.defaultLabelWritingMode) + convenience public init(imageUrl: Foundation::URL, iconSize: CoreFoundation::CGFloat = defaultIconSize, scale: CoreFoundation::CGFloat = 1.0, anchor: CoreFoundation::CGPoint = defaultAnchor, infoAnchor: CoreFoundation::CGPoint = defaultInfoAnchor, debug: Swift::Bool = false, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = ColorDefaultIcon.defaultLabelTextColor, labelTextSize: CoreFoundation::CGFloat = ColorDefaultIcon.defaultLabelTextSize, labelTypeFace: UIKit::UIFont = ColorDefaultIcon.defaultLabelTypeFace, labelStrokeColor: UIKit::UIColor = ColorDefaultIcon.defaultLabelStrokeColor, labelPosition: Swift::String = ColorDefaultIcon.defaultLabelPosition, labelWritingMode: Swift::String = ColorDefaultIcon.defaultLabelWritingMode) final public func toBitmapIcon() -> MapConductorCore::BitmapIcon final public func hashCode() -> Swift::Int final public func copy(image: UIKit::UIImage? = nil, iconSize: CoreFoundation::CGFloat? = nil, scale: CoreFoundation::CGFloat? = nil, anchor: CoreFoundation::CGPoint? = nil, infoAnchor: CoreFoundation::CGPoint? = nil, debug: Swift::Bool? = nil, label: Swift::String? = nil, labelTextColor: UIKit::UIColor? = nil, labelTextSize: CoreFoundation::CGFloat? = nil, labelTypeFace: UIKit::UIFont? = nil, labelStrokeColor: UIKit::UIColor? = nil, labelPosition: Swift::String? = nil, labelWritingMode: Swift::String? = nil) -> MapConductorCore::ImageIcon diff --git a/docs/GroundImageState.swift.md b/docs/GroundImageState.swift.md index d1e19ef..450df95 100644 --- a/docs/GroundImageState.swift.md +++ b/docs/GroundImageState.swift.md @@ -12,6 +12,7 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, @Published public var bounds: GeoRectBounds @Published public var image: UIImage + @Published public var imageUrl: URL? @Published public var opacity: Double @Published public var tileSize: Int @Published public var extra: Any? @@ -26,9 +27,33 @@ public final class GroundImageState: ObservableObject, Identifiable, Equatable, extra: Any? = nil, onClick: OnGroundImageEventHandler? = nil ) + + public convenience init( + bounds: GeoRectBounds, + imageUrl: URL, + opacity: Double = 1.0, + tileSize: Int = 512, + id: String? = nil, + extra: Any? = nil, + onClick: OnGroundImageEventHandler? = nil + ) } ``` +### Loading from a URL + +`GroundImageState(bounds:imageUrl:)` loads the image itself, with the same loader as +`ImageIcon(imageUrl:)`. `https:` and `file:` URLs work. + +- Until the image arrives, `image` is a 1×1 transparent image. When it arrives, it is assigned to + `image` and the overlay is redrawn. +- Setting `imageUrl` to another URL keeps the current image until the new one arrives. If it + changes again before then, the older result is dropped. +- Assigning `image` yourself sets `imageUrl` back to `nil`. +- If loading fails, `image` is left as it is and the failure is logged once per URL. +- `copy()` keeps `imageUrl` unless you pass `image`. +- The generated `id` comes from the URL, so it does not change when the image arrives. + ## Constructor Parameters - `bounds` diff --git a/docs/ImageIcon.swift.md b/docs/ImageIcon.swift.md index 557b97b..5793ee0 100644 --- a/docs/ImageIcon.swift.md +++ b/docs/ImageIcon.swift.md @@ -29,6 +29,29 @@ public final class ImageIcon: MarkerIconProtocol { } ``` +### Loading from a URL + +```swift +public convenience init( + imageUrl: URL, + iconSize: CGFloat = defaultIconSize, + scale: CGFloat = 1.0, + anchor: CGPoint = defaultAnchor, + infoAnchor: CGPoint = defaultInfoAnchor, + debug: Bool = false + // label parameters as in init(image:) +) + +public let imageUrl: URL? // nil for init(image:) +``` + +The icon loads the image itself, through the same loader as `GroundImageState(imageUrl:)`, so each +URL is fetched once however many icons or markers use it. `https:` and `file:` URLs work. + +- Until the image arrives, the icon is a transparent image of the same size and anchor. +- When it arrives, every marker whose `icon` is still this icon is redrawn. +- If loading fails, the icon stays transparent and the failure is logged once per URL. + ## Constructor Parameters - `image` @@ -77,7 +100,7 @@ public func copy( - `ImageIcon.defaultAnchor` is `(0.5, 0.5)` (center), whereas `DefaultMarkerIcon` and `BitmapIcon` default to `(0.5, 1.0)` (bottom-center). -## Example +## Examples ```swift let icon = ImageIcon(image: UIImage(named: "shop_icon")!) @@ -86,3 +109,7 @@ let marker = MarkerState( icon: icon ) ``` + +```swift +let icon = ImageIcon(imageUrl: URL(string: "https://example.com/shop.png")!, iconSize: 32) +``` From 8416fd57611633f4092b24462601e6992c3b0fdc Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 13:22:02 -0800 Subject: [PATCH 2/2] Record Xcode 27.2 as the API baseline toolchain The ImageIcon / GroundImageState URL baseline was dumped with Xcode 27.2 (27B52). Re-dumping the whole baseline with 27.2 changes no other line, so this commit only updates the record. --- api/TOOLCHAIN | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/TOOLCHAIN b/api/TOOLCHAIN index b814f90..aaa19b2 100644 --- a/api/TOOLCHAIN +++ b/api/TOOLCHAIN @@ -23,6 +23,6 @@ # # 差分は「コンパイラが変わった分」として 1 コミットに分け、API の変更と混ぜないこと。 -XCODE_VERSION=27.0 -XCODE_BUILD=27A266a +XCODE_VERSION=27.2 +XCODE_BUILD=27B52 SWIFT_VERSION=6.4