ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

iOS开发实战:网络图片多坐标点标注的坐标映射与缩放联动

iOS开发实战:网络图片多坐标点标注的坐标映射与缩放联动 收到我将按照要求生成一篇关于iOS开发OC 网络图片中 多坐标点位置 添加标注的实战博文。博文将从坐标系映射、图片加载、缩放联动、性能优化和踩坑排查等维度展开全部采用客观的技术分享口吻。现在开始直接输出正文内容不使用任何前置说明和元信息。1. 坐标体系不统一标注永远贴不准——先解决映射问题这周处理了一个很典型的业务需求服务端返回一张网络图片同时下发一批坐标点要求在图片上把这些坐标点对应的位置全部标注出来点击标注可以查看详情。听起来这就是iOS开发里很常见的图文叠加功能但真正动手做的时候才发现里面最折磨人的不是标注视图怎么写而是坐标系。OC这边处理不好坐标映射标注要么整体偏移要么缩放到某个级别后全乱了。这篇文章把我在项目里完整跑通的实现思路和踩坑记录整理出来给正在做类似功能的朋友一份可以直接参考的方案。先说一个最容易忽略的事实网络图片加载完成之前你是不知道它的真实像素尺寸的。UIImageView还没拿到图bounds可能是由占位图决定的而占位图和最终图片尺寸往往不一致。如果一开始就把标注坐标算死了图片加载完成后必然全部错位。1.1 三种坐标来源决定你的实现路径我在实际项目中遇到的坐标来源大致有两种第三种是自己主动统一的坐标类型来源场景特点处理难度经纬度地图截图、遥感图片、巡检定位需要参考点换算无法直接用于UI布局高原图像素坐标图纸、产品图、拍照取证单位是原图像素需等比映射到屏幕中图片相对坐标0~1自己和后端定义的协议尺寸无关适配旋转缩放低如果是第一种比如图片是一张地图截图后端给的坐标点是经纬度那你就不能简单地拿经纬度除以图片宽度去算位置。你得先知道图片上至少两个参考点对应的经纬度然后做线性映射。如果是第二种后端可能是在原始大图上用图像算法框出来的点位传给客户端的是原图的像素坐标这时候客户端要做的是把像素坐标按比例换算到当前显示区域。无论是哪种我都建议在后端接口层就统一成图片相对坐标也就是横纵坐标都用0到1之间的小数表示。这样客户端不用关心图片多大、屏幕几倍率只需要在拿到图片后把相对坐标乘上实际显示区域尺寸就行。1.2 统一到图片相对坐标为什么这是最优解我之所以强调相对坐标是因为它天然具备尺寸无关这个特性。同一张网络图片在同一台设备上可能以300pt宽度展示在另一台设备上可能是375pt同一张图片在2x、3x屏幕上显示的物理像素也不同。如果接口下发的是原图像素坐标客户端换算的复杂度就上来了还得知道图片的原始像素宽高这个值在网络加载完成前是拿不到的。相对坐标就不存在这个问题。假设后端返回rx 0.35ry 0.62客户端只需要记住这个比例等图片加载完成、拿到实际显示区域后乘以宽度和高度就是标注的中心点。整个计算过程完全不依赖图片原始尺寸也不需要关心设备屏幕密度变化。当然如果没有权限改后端接口客户端这边也可以通过换算把原图像素坐标转成相对坐标。比如原图是1200x800一个点的像素坐标是(420, 500)转成相对坐标就是(420/1200, 500/800) (0.35, 0.625)。所以相对坐标是最稳妥的中间态客户端自己转换也非常简单。1.3 经纬度如何换算到图片坐标有朋友可能会问如果后端给的是经纬度而且图片就是一张普通照片不是标准地图那怎么办这种场景在施工巡检、安防取证里很常见照片是现场拍的坐标点是通过定位设备采集的经纬度。要做标注必须先建立经纬度到图片像素/相对坐标的映射关系。常规做法是利用两个已知的锚点。假设图片左上角和右下角分别对应两个已知经纬度每个锚点能提供一个坐标对两个锚点就可以构造一个简单的线性映射关系// 已知锚点1经纬度(lat1, lon1) 对应 图片相对坐标(rx1, ry1) // 已知锚点2经纬度(lat2, lon2) 对应 图片相对坐标(rx2, ry2) - (CGPoint)relativePointFromLatitude:(double)lat longitude:(double)lon { double x (lon - lon1) / (lon2 - lon1) * (rx2 - rx1) rx1; double y (lat - lat1) / (lat2 - lat1) * (ry2 - ry1) ry1; return CGPointMake(x, y); }这种映射假设拍摄区域较小、可以近似为平面在几百米范围内的场景误差不大。如果图片覆盖范围很大或者要求高精度那就不能自己算了建议直接上地图SDK的GroundOverlay方案这个我在第五章节具体讲。提示经纬度换算还有一个隐藏问题就是经纬度的纬度差和经度差对应的实际地面距离在不同纬度下不是等比的。如果图片覆盖范围超过1公里用简单线性映射会有形变锚点越多校正越准。业务上最好要求后端提供至少3个锚点方便做校正。2. 图片加载与标注容器的落地组合坐标系问题想清楚之后接下来才是工程实现。这里最核心的决策是视图层级怎么组织。我见过不少直接把标注视图addSubview到UIImageView上的做法短期看没问题一旦涉及缩放、旋转、大量标注复用时坑就来了。2.1 图片加载选型SDWebImage的成熟链路网络图片加载在iOS里其实没什么好纠结的直接用SDWebImage就对了。它解决了几个自己写会非常头疼的问题内存缓存和磁盘缓存策略成熟同一张图片二次加载基本秒出支持渐进式加载弱网下体验更好提供了UIImageViewWebCache分类一行代码搞定completed回调里能拿到最终image和imageURL便于做后续布点。核心代码就这一步#import SDWebImage/UIImageViewWebCache.h [self.imageView sd_setImageWithURL:imageURL placeholderImage:[UIImage imageNamed:placeholder] completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) { if (image !error) { // 图片加载完成拿到image.size后开始布点 [self relayoutAnnotations]; } }];这里有个细节completed回调里拿到的image.size是图片的像素尺寸不是UIImageView的显示尺寸。在图片被ScaleAspectFit或ScaleAspectFill展示的情况下真实显示区域只是imageView.bounds的一部分。所以布点时不能直接拿image.size去算要先算出图片的实际显示区域rect这个我在第四章的坐标漂移坑里详细展开。2.2 视图层级设计为什么要单独建一个标注容器我建议的层级结构是这样的UIScrollView负责缩放和滚动UIImageView显示网络图片AnnotationContainerView标注容器与ImageView平级frame跟随图片显示区域为什么不把标注直接加到UIImageView上有三个方面原因。第一UIImageView在ScrollView缩放时它的frame和bounds会发生变化直接加在上面的子视图也会跟着一起缩放变形。如果你不希望标注文字和图标被拉伸成马赛克就需要一个独立的层来控制缩放行为。第二大量标注的复用、显示、隐藏需要一个统一的容器管理。如果标注分散在UIImageView的几个子层级中做命中测试、复用池、可见区域筛选都会很麻烦。第三标注层虽然要显示在图片上方但它的交互逻辑和图片滚动是冲突的。独立容器方便统一处理hitTest点中标注就响应点击点不中就放行给ScrollView滚动。AnnotationContainerView本身可以继承UIView什么都不用做只需要确保它的frame和图片的实际显示区域一致// 在图片加载完成后、bounds确定后调用 self.annotationContainerView.frame [self imageDisplayRect];2.3 添加标注与命中测试的实现细节标注视图一般是一个自定义UIView包含图标和文本中心点对准坐标点。添加标注时按相对坐标换算到容器坐标- (void)addAnnotationView:(AnnotationView *)annotationView atRelativePoint:(CGPoint)relativePoint { CGPoint center CGPointMake(relativePoint.x * self.annotationContainerView.bounds.size.width, relativePoint.y * self.annotationContainerView.bounds.size.height); annotationView.center center; annotationView.relativePoint relativePoint; [self.annotationContainerView addSubview:annotationView]; }注意relativePoint要存下来后面缩放、旋转、重新布局都要用它重新计算位置。每一个标注视图最好携带一个业务模型比如annotationId、title、parsedData点击时通过它回调给上层业务。命中测试涉及两个选择。早期我图省事直接给每个标注视图加一个UITapGestureRecognizer点自己就触发看起来没问题。但标注数量一多手势多了会拖慢首次响应而且标注之间如果有重叠后加的视图会把前面的点击吃掉。后来我改成了在容器层统一做hitTest- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { // 倒序遍历保证最上层的标注优先响应 for (UIView *subview in self.annotationContainerView.subviews.reverseObjectEnumerator) { UIView *result [subview hitTest:[self.annotationContainerView convertPoint:point toView:subview] withEvent:event]; if (result) { return result; } } return nil; }这个方案的核心思路是容器本身不开userInteractionEnabled但做一个转发逻辑点中哪个标注就返回那个标注视图没点中任何标注就返回nil事件自然落到下层的ScrollView上图片还能正常滑动和缩放。注意标注视图的点击区域默认只有它自身frame那么大如果图标很小用户很难点中。建议在标注视图内部至少留一个44x44的扩大点击区域或者实现pointInside:withEvent:来判断把命中区域扩展到可视范围之外。3. 缩放、旋转下的标注联动与性能取舍图片标注功能很少是静态展示的用户大概率会捏合缩放图片来看细节。缩放场景下标注如何跟随是一个绕不开的抉择。3.1 两种跟随策略整层缩放与逐点重算我在实测中总结了两种策略适用场景完全不同策略实现方式优点缺点适用场景整层缩放给AnnotationContainerView设置transform实现简单坐标不用重算性能好标注文字和图标被放大会模糊纯展示型标注图钉、圆点、水印逐点重算每次zoomScale变化时重新计算所有标注位置标注始终固定大小清晰可读标注多时计算量增加需要做懒加载交互型标注按钮、文本标签、详细卡片如果你做的是资产盘点、图纸审批这类功能标注通常是可点击的按钮上面带文字那就必须用逐点重算不要让标注跟着图片一起变大。否则用户在3倍缩放下看到的标注文字是模糊的体验非常差。逐点重算的核心代码放在scrollViewDidZoom:里- (void)scrollViewDidZoom:(UIScrollView *)scrollView { // container始终覆盖图片当前显示区域 self.annotationContainerView.frame self.imageView.frame; if (!self.fixedSizeAnnotations) return; for (AnnotationView *annotation in self.visibleAnnotations) { annotation.center CGPointMake(annotation.relativePoint.x * self.annotationContainerView.bounds.size.width, annotation.relativePoint.y * self.annotationContainerView.bounds.size.height); } }因为annotationContainerView的frame跟随imageView的frame而UIScrollView缩放后imageView的frame正好反映了缩放后的显示区域所以用相对坐标乘以container.bounds.size就能得到正确的屏幕位置标注自身尺寸完全不变始终保持清晰。如果坚持用整层缩放实现确实简单在scrollViewDidZoom:里写一行就行self.annotationContainerView.transform CGAffineTransformMakeScale(scrollView.zoomScale, scrollView.zoomScale);但你要接受标注内的字体、图标、圆角全部被拉伸的事实。用于纯装饰性标注没问题用于功能性标注还是选逐点重算。3.2 大量标注的复用池与可视区域懒加载当标注数量超过50个逐点重算和添加视图的开销就开始变得明显超过200个如果每个标注都是一个独立的UIView滑动时卡顿几乎是必然的。我在这类需求里的做法是两板斧复用池 懒加载。复用池的思路和UITableView的cell复用完全一样。维护一个NSMutableSet存待复用的AnnotationView标注滑出可视区域时从容器移除并放进复用池需要显示新标注时先从复用池取取不到再新建- (AnnotationView *)dequeueAnnotationView { AnnotationView *view [self.reusePool anyObject]; if (view) { [self.reusePool removeObject:view]; } else { view [[AnnotationView alloc] init]; } return view; }懒加载则进一步减少UIView的数量。在图片滚动或缩放时计算当前ScrollView的contentOffset和contentSize换算成相对坐标范围只把落在可见范围内的标注添加到容器里。比如图片宽度是1000pt当前显示区域是从200pt到600pt那相对坐标x在0.2到0.6之间、y在可见范围内的标注才需要显示。这个筛选是纯粹的数学比较效率很高。这两个优化配合起来即使标注数量达到上千个实际在视图层级里的AnnotationView也始终保持在几十个的量级滑动起来是流畅的。3.3 旋转与横竖屏切换的坐标重算如果你的图片支持旋转坐标换算是另一个坑。假设图片顺时针旋转90度原相对坐标(rx, ry)在新坐标系中会变成(1 - ry, rx)。这个映射关系初期很容易搞错我当时的验证方法是把四个角落的坐标点全部打印出来旋转前后逐一比对才确认公式正确。更通用的方式是把坐标映射做到一个独立的工具类里不要散落在ViewController各段业务代码中。比如设计一个CoordinateMapper提供相对坐标、像素坐标、经纬度坐标三种表示之间的转换方法旋转和缩放时只调工具类方法业务层完全不用关心底层换算逻辑。横竖屏切换同理。如果是iPhone自动旋转annotationContainerView的frame会跟着imageView变化但相对坐标布局天然自适应只需要在所有标注中心点重新set一次即可。这里有个细节如果旋转动画过程中触发多次layout可以考虑在viewWillTransitionToSize:withTransitionCoordinator:里先暂停重算等动画结束后一次性更新避免标注在旋转过程中来回跳动。4. 实测中踩过的坐标漂移、时序与触摸坑这章把我在这个功能上实打实踩过的坑写出来都是排查了很久才定位到的问题复现思路比最终答案更有参考价值。4.1 contentMode导致坐标漂移完整排查链路现象图片加载完成后所有标注点整体向右下方向偏移偏移量随着图片宽高比偏差增大而增大。第一次排查我以为是相对坐标换算写错了检查了每个点的算法打印出来的计算结果和理论上完全一致。第二次排查打印了UIImageView的bounds发现bounds和图片像素尺寸比例不一致。继续打印image.size发现原图比例是4:3而imageView在屏幕上被拉成了接近16:9的比例。背景图用了ScaleAspectFit图片上下有黑边。而我的annotationContainerView的frame用的是imageView.bounds没有减去上下黑边导致标注整体偏到了底部。根因UIImageView显示图片时如果图片宽高比和view的宽高比不一致ScaleAspectFit模式下图片的真实显示区域小于view.bounds。直接用bounds做换算坐标自然漂移。修复写一个专门计算图片实际显示区域的方法容器和坐标换算都基于这个真实区域- (CGRect)imageDisplayRectInImageView:(UIImageView *)imageView { UIImage *image imageView.image; if (!image) return imageView.bounds; CGSize imageSize image.size; CGRect viewBounds imageView.bounds; CGFloat imageRatio imageSize.width / imageSize.height; CGFloat viewRatio viewBounds.size.width / viewBounds.size.height; CGRect displayRect CGRectZero; if (imageRatio viewRatio) { displayRect.size.width viewBounds.size.width; displayRect.size.height viewBounds.size.width / imageRatio; displayRect.origin.x 0; displayRect.origin.y (viewBounds.size.height - displayRect.size.height) / 2.0; } else { displayRect.size.height viewBounds.size.height; displayRect.size.width viewBounds.size.height * imageRatio; displayRect.origin.y 0; displayRect.origin.x (viewBounds.size.width - displayRect.size.width) / 2.0; } return displayRect; }之后所有标注容器的frame和相对坐标换算都用imageDisplayRect的尺寸而不是imageView.bounds。这个问题修好后漂移彻底消失。4.2 网络图片加载完成前的时序问题现象在弱网环境下图片加载时间长用户看到占位图。占位图尺寸和网络图片不一致此时标注已经按占位图的尺寸放在了错误位置。网络图片加载完成之后标注没有自动纠正。根因加载完成回调里做了重新布点但由于回调里的image是新的而容器的frame没有同步更新导致重算后还是错位。另一个更隐蔽的问题是如果一个页面连续展示多张图片快速切换时旧图片的异步回调可能覆盖新图片的标注数据。修复在加载前记录当前的URL字符串回调里先判断返回的imageURL是否等于当前URL防止串图__weak typeof(self) weakSelf self; self.currentURLString url.absoluteString; [self.imageView sd_setImageWithURL:url placeholderImage:placeholder completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) { __strong typeof(weakSelf) self weakSelf; if (![imageURL.absoluteString isEqualToString:self.currentURLString]) { return; // 旧图片的回调直接丢弃 } if (image !error) { self.imageDisplayRect [self imageDisplayRectInImageView:self.imageView]; [self reloadAllAnnotations]; } }];然后把需要显示的坐标点数据保存到一个数组里reloadAllAnnotations方法统一从数组读取、按新的显示尺寸重新添加标注而不是在回调里逐个增量添加。这样无论图片何时加载完成最终的状态都是对的。4.3 标注层挡住手势hitTest的改造现象标注添加多了以后用户想滑动图片却发现手势经常被标注所在区域截获划不动有时候点图片空白区域却莫名其妙触发了一个标注的点击事件。根因标注容器默认开启userInteractionEnabled并且每个标注视图都加了点击手势视图层级上标注容器覆盖在整个图片上方手势被它拦截。这里的核心矛盾是标注既要响应点击又不能干扰图片的滚动和缩放。我最终采用的方案在前面已经提到了统一在容器层的hitTest:里做有就转发、没有就放行的逻辑。这里再补充一个细节容器本身不要加任何手势所有事件都交给底层ScrollView的pan和pinch手势处理。当点击落在标注视图范围内时hitTest返回对应子视图事件被标注处理否则返回nil事件继续传给ScrollView。还有一个容易被忽略的细节当用户在图片上做捏合缩放时两个手指可能一个点在标注上、一个点在图片上。如果标注视图拦截了其中一根手指的事件缩放手势会失败。所以标注视图内部如果实现了touchesBegan等触摸方法要确保在多指场景下能够正确透传。比较稳妥的办法是不在标注视图里做复杂的手势只用UITapGestureRecognizer的轻点捏合缩放交给系统处理。调试技巧标注位置对不对不要靠肉眼猜。我习惯在每个标注的中心点额外画一个1x1pt的红色小点真机上对比预期坐标快速确认公式是否出错。这个小点在截图对比、上下偏差排查时特别有用排查完再隐藏掉。5. 业务变体经纬度地图场景与跨端方案延伸同一个网络图片加坐标点标注的需求落到不同业务形态里解法会有明显差异。这里聊两个我接触过的变体场景。5.1 地图截图的经纬度标注直接用地图SDK如果图片本身就是某个地图SDK截图比如高德地图、百度地图的截图而后端返回的坐标又是经纬度那我的建议是不要自己写坐标换算。用地图SDK的GroundOverlay能力把图片作为一个覆盖图层加载到地图上然后直接把经纬度标注作为地图的Marker或Annotation添加。这样缩放、旋转、坐标系全交给地图SDK处理你只需要管理标注数据。这种做法的代价是引入地图SDK的包体积和初始化流程但精度和稳定性远高于自己实现。我在一个巡检项目里把自研的坐标映射方案换成了地图SDK方案经纬度点位偏差从原来的几十米下降到了米级而且再也不用维护锚点数据了。如果确实不方便引入地图SDK比如图片只是一张普通的现场照片锚点方案配合线性映射是够的前提是业务方接受这个精度范围。5.2 小程序与跨端开发中的同一套坐标协议现在跨端开发很普遍同一个功能可能在微信小程序、uni-app、Flutter里都要实现。我的一个经验是先定义好坐标协议再谈具体客户端实现。服务端下发标注点时如果直接给相对坐标比如rx: 0.35, ry: 0.62那不管是iOS的OC、Swift还是小程序的WXML还是Flutter的Stack布局都能用同一套逻辑换算。微信小程序里实现这类功能核心也是等待图片加载拿到宽高参数再计算位置。小程序的image组件有bindload事件在事件回调里e.detail.width和e.detail.height就是图片真实宽度然后用百分比或者rpx换算标注定位。uni-app的canvas方案本质上也是先拿到图片实际像素再按坐标比例绘制标注。所以我很建议在接口设计阶段就统一成相对坐标这样跨端实现会非常轻松。如果已经存在旧接口下发的是像素坐标客户端或者服务端加一个转换层也就一两行代码的事长期利大于弊。5.3 复盘一个成功的图片标注功能该怎么设计如果让我重新做一次这个功能我会把三件事放在最前面第一坐标协议。第一时间和服务端对齐统一使用相对坐标所有换算逻辑收敛到前端一个工具类里这是整个功能的基石。第二视图结构。固定使用ScrollView承载图片标注容器独立于ImageView一开始就规划好复用池和懒加载的接口不要等标注数量上来了再重构。第三时序控制。从网络图片加载完成到手势交互的完整逻辑要在设计阶段就理清楚特别是异步回调的串图问题避免上线后弱网环境复现难排查的bug。整个功能做下来我的最大体会是图片标注看似是UI层面的工作实际上大部分精力花在了坐标换算和异步时序上。把这两点想明白剩下的都是按部就班写视图。再加上缩放联动和性能优化一个能应付真实业务的图片标注功能就能稳定落地了。
返回列表