今年夏天,我计划带着我的孩子出国。
她很兴奋。
在此之前,我和妻子决定大肆宣传一下这次的飞行之旅,主要是为了确保女儿能安稳地度过3小时的飞行时间。
可能是我们宣传有点过头了,以至于当我们不得不坐出租车去机场时,我蹒跚学步的孩子感到震惊——她原本以为会从我们家直接走上飞机。
我们登机后,发生了一件令人难以置信的事情。
原来,当机组人员发现你和一个痴迷于飞机的可爱小孩在一起时,他们会邀请你们去看看驾驶舱。
这激发了我女儿对飞机的痴迷。
从那之后,她一直要求我在天上为她寻找飞机,当我为她找到一架飞机时,她很高兴。
上周,我们在花园里待了一个小时,她坐在我的肩上,看着飞机一架接一架地在夜空中闪烁。
后来我找到了FlightRadar24,它能显示覆盖在地图上的飞机位置,但美中不足的是,我必须自己调整方向。
但是,对于一个孩子来说,她可能并不真正理解或关心地图是什么。
所以我们有了继续解决的新问题,比如方向,比如可用性。
作为一名非物理移动技术主管,我确实不知道从哪里开始为孩子打造一匹摇马,但没有什么能阻止我把这个想法变成一个很酷的应用程序。
通过研究制定的要求:
这些要求导致了一些构成概念验证的活动部分:
对于图标,我选择了一幅女儿戴着可爱飞行员帽的卡通画。所以我们已经有了应用程序名称:Aviator。
第一个关键差异化产品要求是保持方向。
为了使用便利,屏幕上的对象需要与其现实生活中的位置相对应。因此,当用户旋转时,屏幕本身也会旋转并保持指向北。
final class LocationManager: CLLocationManager, CLLocationManagerDelegate { static let shared = LocationManager() private(set) var rotationAngleSubject = CurrentValueSubject<Double, Never>(0) override private init() { super.init() requestWhenInUseAuthorization() delegate = self startUpdatingHeading() } func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { rotationAngleSubject.send(-newHeading.magneticHeading) }}
同时,为了获得好看的指南针效果,我还绘制了一组随旋转角度变化的矩形。
@State private var rotationAngle: Angle = .degrees(0)var body: some View { ZStack { ForEach(0..<36) { let angle = Angle.degrees(Double($0 * 10)) + rotationAngle Rectangle() .frame(width: $0 == 0 ? 16 : 8, height: $0 == 0 ? 3 : 2) .foregroundColor($0 == 0 ? .red : .blue) .rotationEffect(angle) .offset(x: 120 * cos(CGFloat(angle.radians)), y: 120 * sin(CGFloat(angle.radians))) .animation(.bouncy, value: rotationAngle) } } .onReceive(LocationManager.shared.rotationAngleSubject) { angle in rotationAngle = Angle.degrees(angle) }}
看起来相当不错,而且也完美地响应了我的真实位置。
可能你会注意到一个有趣的视觉故障,因为动画逻辑将0度和360度视为单独的数字——当我经过正北时,所有矩形都会旋转。
热身结束,接下来是重要的部分。
OpenSky Network API允许用户给定一系列纬度和经度,通过一个简单的请求返回该范围内的本地航班数组。这意味着,只需将其粘贴到浏览器中,即可找出我可以看到的头顶上空的航班数据。
REST API记录良好,但数据按顺序显示为列表属性。
我们需要去解码它,让其按顺序从JSON响应中解析出字段。
struct Flight: Decodable { let icao24: String let callsign: String? let origin_country: String? let time_position: Int? let last_contact: Int let longitude: Double let latitude: Double // ... init(from decoder: Decoder) throws { var container = try decoder.unkeyedContainer() icao24 = try container.decode(String.self) callsign = try? container.decode(String?.self) origin_country = try container.decode(String.self) time_position = try? container.decode(Int?.self) last_contact = try container.decode(Int.self) longitude = try container.decode(Double.self) latitude = try container.decode(Double.self) // ... }}
我们还可以编写一个简单的API,根据用户的位置坐标执行请求。
final class FlightAPI { func fetchLocalFlightData(coordinate: CLLocationCoordinate2D) async throws -> [Flight] { let lamin = String(format: "%.1f", coordinate.latitude - 0.25) let lamax = String(format: "%.1f", coordinate.latitude + 0.25) let lomin = String(format: "%.1f", coordinate.longitude - 0.5) let lomax = String(format: "%.1f", coordinate.longitude + 0.5) let url = URL(string: "https://opensky-network.org/api/states/all?lamin=/(lamin)&lamax=/(lamax)&lomin=/(lomin)&lomax=/(lomax)")! let data = try await URLSession.shared.data(from: url).0 return try JSONDecoder().decode([Flight].self, from: data) }}
这样飞行数据就被很好地解析为内存中对象的数组,也变得易于处理。
如何实际测试飞机图纸的准确性?
我们可以在这些所有东西下面画一张地图:AviatorView顶部的指南针,绘制到屏幕上的飞机,以及朴素的SwiftUI视图。
@State private var cameraPosition: MapCameraPosition = .camera(MapCamera( centerCoordinate: CLLocationCoordinate2D(latitude: 51.0, longitude: 0.0), distance: 100_000, heading: 0))var body: some View { ZStack { Map(position: $cameraPosition) { } airplanes compass }}
这是我第一次熬夜跑出来的结果,与作为事实来源的FlightRadar进行比较。
可以看到,天空中飞机的数量和集群看起来都差不多,但位置却相差甚远。忽然,我灵光一闪,原来还需要使用注释在地图上绘制飞机。
这个想法我已经酝酿了一整天:我们使用地图,然后在其精确地理位置的顶部绘制飞机形状的注释,最终,我想找到一种方法来隐藏实际地图,并仅将飞机显示为雷达位置上的标记。
这应该会给我们带来我们想要的很酷的、完全定向的雷达效果。
在iOS 17中,在地图上绘制注释非常简单。
import MapKitimport SwiftUIstruct FlightMapView: View { @Binding var cameraPosition: MapCameraPosition let flights: [Flight] var body: some View { Map(position: $cameraPosition) { planeMapAnnotations } .mapStyle(.imagery) .allowsHitTesting(false) }}
在这里,出于雷达的目的,我们希望防止命中测试——即我不希望地图是交互式的。在构想中,地图是不可见的,用户只能看到航班及其位置。
定位之后,尺寸调整是下一个核心问题,现有的解决方案根本无法很好地处理这个问题。
我使用飞行高度在地图注释中添加了一些简单的对数缩放,以便更高的飞机在屏幕上显得更大。此外,我使用飞机的真实属性,结合核心位置中的用户方向,来显示飞机面向正确的方向。
@State private var rotationAngle: Angle = .degrees(0)private var planeMapAnnotations: some MapContent { ForEach(flights, id: /.icao24) { flight in Annotation(flight.icao24, coordinate: flight.coordinate) { let rotation = rotationAngle.degrees + flight.true_track let scale = min(2, max(log10(height + 1), 0.5)) Image(systemName: "airplane") .rotationEffect(.degrees(rotation)) .scaleEffect(scale) } } .tint(.white) }}
现在是进行终极测试的时候了。
我和女儿一起去看飞机,现在我们有了真实的地图注释,能在地图上显示用户的位置和方向。最重要的是,它能够准确地找到飞机!
这获得了巨大成功,因为我们在这上面找到了飞机。
初步测试还得出了两条重要信息。
首先,缩放逻辑是不正确的。看看伦敦城市机场地面上的小飞机。由于应用程序的重点是定位天空中的飞机,因此我们需要反转缩放比例,较低的平面必须显示得更大,因为我们是用眼睛来发现它们的。
其次,我的孩子不关心地图,只关心飞机。如果我想消除噪音并专注于发现飞机,我需要删除地图,并开始建造我的雷达!
我轻松地修复了飞机的缩放逻辑。
经过一番尝试和错误后,为了查看屏幕上看起来不错的内容,并给出合理的尺寸分布,我选择了缩放:
min(2, max(4.7 - log10(flight.geo_altitude + 1), 0.7))
这些缩放来自我的本地开销扫描:
Scale: 1.0835408863965839Scale: 0.8330645861650874Scale: 1.095791123396205Scale: 1.1077242935783653Scale: 2.0Scale: 1.4864702267977097Scale: 0.7
我几乎准备好建造我所设想的雷达了,但是出现了一个问题。
开源OpenSky API不断超时,返回502错误,或者有时生成带有空数据的200响应。
这其实也不是问题,毕竟这不是个企业级应用程序,而且这个API不需要我花任何费用。他们没有SLA,我也觉得自己没有资格获得SLA。不过为了帮助提高客户端的稳健性,我在API调用中实现了一些基本的重试逻辑:
private func fetchFlights(at coordinate: CLLocationCoordinate2D, retries: Int = 3) async { do { try await api.fetchLocalFlightData(coordinate: coordinate) } catch { if retries > 0 { try await fetchFlights(at: coordinate, retries: retries - 1) } }}
第二天,API运行良好,除了某些高流量时刻外。
最重要的降噪任务是使实际地图不可见。没有这个雷达就无法工作。
我能够使用MapPolygon来做到这一点,表面上设计这样你就可以放置叠加层来突出显示地图的各个部分。但我想用它来隐藏除注释之外的所有内容。
struct FlightMapView: View { var body: some View { Map(position: $cameraPosition) { planeMapAnnotations MapPolygon(overlay(coordinate: coordinate)) } .mapStyle(.imagery) .allowsHitTesting(false) } // ... private func rectangle(around coordinate: CLLocationCoordinate2D) -> [CLLocationCoordinate2D] { [ CLLocationCoordinate2D(latitude: coordinate.latitude - 1, longitude: coordinate.longitude - 1), CLLocationCoordinate2D(latitude: coordinate.latitude - 1, longitude: coordinate.longitude + 1), CLLocationCoordinate2D(latitude: coordinate.latitude + 1, longitude: coordinate.longitude + 1), CLLocationCoordinate2D(latitude: coordinate.latitude + 1, longitude: coordinate.longitude - 1) ] } private func overlay(coordinate: CLLocationCoordinate2D) -> MKPolygon { let rectangle = rectangle(around: coordinate) return MKPolygon(coordinates: rectangle, count: rectangle.count) }}
这种方法很有效!
我们现在可以看到飞机,但看不到地图,就像我们想要的那样。
最关键的是,苹果将叠加层设计为位于地图顶部、注释下方,如果他们采取其他方式,我女儿的新玩具就会跛行。
核心需求的最后一部分是雷达视图,这本质上是一组直线、同心圆和20度的旋转角梯度。
难不倒我。
经过三个晚上的辛苦工作,女儿终于开始对我创造的玩具表现出一些兴趣。
我们已经证明了这个概念,并构建了一个 MVP,可以实现我们设定的核心初始目标。
现在可以考虑把它放到App Store上了。
当然在此之前还需要进行其他的优化。
比如让雷达有360度宽角渐变,从绿色,到透明,到透明,到透明,再到黑色。
private var radarLine: some View { Circle() .fill( AngularGradient( gradient: Gradient(colors: [ Color.black, Color.black, Color.black, Color.black, Color.black.opacity(0.8), Color.black.opacity(0.6), Color.black.opacity(0.4), Color.black.opacity(0.2), Color.clear, Color.clear, Color.clear, Color.clear, Color.clear, Color.clear, Color.clear, Color.clear, Color.clear, Color.clear, Color.clear, Color.green]), center: .center, startAngle: .degrees(rotationDegree), endAngle: .degrees(rotationDegree + 360) ) ) .rotationEffect(Angle(degrees: rotationDegree)) .animation(.linear(duration: 6).repeatForever(autoreverses: false), value: rotationDegree)}
除此之外,我添加了CRT屏幕效果和电视扫描线,使应用程序看起来就像是在旧雷达扫描仪上绘制的。
#include <metal_stdlib>using namespace metal;[[ stitchable ]] half4 crtScreen( float2 position, half4 color, float time) { if (all(abs(color.rgb - half3(0.0, 0.0, 0.0)) < half3(0.01, 0.01, 0.01))) { return color; } const half scanlineIntensity = 0.2; const half scanlineFrequency = 400.0; half scanlineValue = sin((position.y + time * 10.0) * scanlineFrequency * 3.14159h) * scanlineIntensity; return half4(color.rgb - scanlineValue, color.a);}
我还创建了一个视图修改器,可以将CRT效果应用到喜欢的任何视图。
extension View { func crtScreenEffect(startTime: Date) -> some View { modifier(CRTScreen(startTime: startTime)) }}struct CRTScreen: ViewModifier { let startTime: Date func body(content: Content) -> some View { content .colorEffect( ShaderLibrary.crtScreen( .float(startTime.timeIntervalSinceNow) ) ) }}
目前该应用程序已经上线了App Store。
同时下个版本的新功能也已经在构想中了,包括但不限于:
本文链接:http://www.28at.com/showinfo-26-34927-0.html孩子喜欢飞机,于是我给她做了一个雷达
声明:本网页内容旨在传播知识,不代表本站观点,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。
上一篇:C++数据与量值是如何被组织的?
下一篇:理解C++之构造函数