今年夏天,我计划带着我的孩子出国。
她很兴奋。
在此之前,我和妻子决定大肆宣传一下这次的飞行之旅,主要是为了确保女儿能安稳地度过3小时的飞行时间。
可能是我们宣传有点过头了,以至于当我们不得不坐出租车去机场时,我蹒跚学步的孩子感到震惊——她原本以为会从我们家直接走上飞机。
我们登机后,发生了一件令人难以置信的事情。
原来,当机组人员发现你和一个痴迷于飞机的可爱小孩在一起时,他们会邀请你们去看看驾驶舱。
这激发了我女儿对飞机的痴迷。
从那之后,她一直要求我在天上为她寻找飞机,当我为她找到一架飞机时,她很高兴。
上周,我们在花园里待了一个小时,她坐在我的肩上,看着飞机一架接一架地在夜空中闪烁。
后来我找到了FlightRadar24,它能显示覆盖在地图上的飞机位置,但美中不足的是,我必须自己调整方向。
但是,对于一个孩子来说,她可能并不真正理解或关心地图是什么。
所以我们有了继续解决的新问题,比如方向,比如可用性。
作为一名非物理移动技术主管,我确实不知道从哪里开始为孩子打造一匹摇马,但没有什么能阻止我把这个想法变成一个很酷的应用程序。
在雷达上显示附近的航班
通过研究制定的要求:
- 该应用程序需要保持正确的方向,随设备旋转,以便显示飞机的正确方向。
- 该应用程序必须根据飞机的高度将飞机图标显示为更大或更小。
- 该应用程序必须很有趣,要有一种复古儿童玩具的感觉,而不是严肃的商业应用程序。
这些要求导致了一些构成概念验证的活动部分:
- 保持方向是差异化产品的核心要求,因为现有解决方案缺少这一点。我不关心详细的航班信息,我只是想制作一个很酷的雷达。iOS 核心位置API已被涵盖,每次用户重新调整设备方向时都会提供委托回调。
- 最重要的组件是Flight Data API。OpenSky Network正是我所需要的。一个简单的REST API,免费供非商业用途,包含某个区域的航班实时数据。我们希望每隔几秒就对这个端点执行操作,以进行真实的雷达扫描。
- 为了调用 API,还需要一些位置数据。Core Location可供查询距用户位置+/-1度的纬度,精度为0.1度(约10公里),以确保用户的位置足够模糊。我们也只需要在每个会话中获取一次该数据。
- 最后,我们需要重新掌握三角学技能,将飞行位置数据与我们自己的定向坐标进行比较。这将使我们能够根据附近的飞机在天空中与我们的相对位置,将其绘制到屏幕上的正确位置。
概念验证
对于图标,我选择了一幅女儿戴着可爱飞行员帽的卡通画。所以我们已经有了应用程序名称: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进行比较。
可以看到,天空中飞机的数量和集群看起来都差不多,但位置却相差甚远。忽然,我灵光一闪,原来还需要使用注释在地图上绘制飞机。
MVP
这个想法我已经酝酿了一整天:我们使用地图,然后在其精确地理位置的顶部绘制飞机形状的注释,最终,我想找到一种方法来隐藏实际地图,并仅将飞机显示为雷达位置上的标记。
这应该会给我们带来我们想要的很酷的、完全定向的雷达效果。
地图注释
在iOS 17中,在地图上绘制注释非常简单。
import MapKit
import SwiftUI
struct 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.0835408863965839
Scale: 0.8330645861650874
Scale: 1.095791123396205
Scale: 1.1077242935783653
Scale: 2.0
Scale: 1.4864702267977097
Scale: 0.7
创建雷达
我几乎准备好建造我所设想的雷达了,但是出现了一个问题。
API稳健性
开源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度的旋转角梯度。
难不倒我。
用户调研2
经过三个晚上的辛苦工作,女儿终于开始对我创造的玩具表现出一些兴趣。
我们已经证明了这个概念,并构建了一个 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。
同时下个版本的新功能也已经在构想中了,包括但不限于:
- 向地图添加缩放级别,以将雷达限制为仅检测较近的飞机。
- 使用OpenSky Network API的高级版本显示直升机、卫星和飞机尺寸类别。
- 切换飞机上的出发地和目的地国家/地区显示。
- 使用更先进的金属着色器改善CRT屏幕效果。
- 实施滑块控件来过滤掉某些距离和高度,例如隐藏所有低矮、遥远的飞机。
- 实施“滑稽模式”,在雷达上呈现不明飞行物、巨型虫子和外星人。