先交代一下我遇到这个问题的背景。上个月做一个地图选点功能,业务上需要这样的效果:用户在地图上点中某个商家后,除了被选中的那个店,其余区域都要蒙一层半透明深色遮罩,形成一种“聚焦”的视觉。实现的时候我图省事,直接用高德SDK往地图上铺了一张GroundOverlay当遮罩,又给选中的Marker设了很高的zIndex,想让Marker穿过遮罩显示。结果一上线就陆续收到反馈,遮罩在某些手机上盖不住Marker,有些机型上又反过来把选中Marker啃掉半边,还有人说地图转动后遮罩边缘直接“漏光”。后来花了一整天排查,最后发现根本不是zIndex数值不够大的问题,而是我一开始就把遮罩层放在了不该放的位置。整个过程踩了不少坑,把这篇文章写下来,希望能给正在被高德Android SDK层级问题折磨的朋友指条近路。
需要先说明的是,文章里的结论主要基于我在多个项目里实测出来的经验,高德SDK不同版本之间确实会有些行为差异,但排查思路和最终方案是通用的。
1. 现象:半透明遮罩包不住Marker,选中点直接“冲破”图层
先还原一下这个功能最初是怎么写的,以及遇到的问题到底长什么样。因为很多问题光看代码是抽象的,得先知道现象才能对得上号。
1.1 业务场景与最初实现
需求拆解下来其实不复杂:一个地图页面,地图上散布着若干个门店Marker;用户点击某个门店后,该门店的Marker保持正常明亮显示,地图的其他区域覆盖一层半透明深色遮罩。处理这类需求,大部分人的第一反应都是“给地图加一个覆盖物”,我当时选的方案是高德的GroundOverlay,把一张半透明的深色位图铺在地图上,代码大概是这样的:
Bitmap maskBitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(maskBitmap); canvas.drawColor(Color.argb(190, 17, 20, 28)); GroundOverlayOptions overlayOptions = new GroundOverlayOptions() .position(targetLatLng, 2000f, 2000f) // 以选中点为中心的一块矩形区域 .bitmap(BitmapDescriptorFactory.fromBitmap(maskBitmap)) .zIndex(0f); GroundOverlay maskOverlay = aMap.addGroundOverlay(overlayOptions);为了让选中的门店Marker能“穿透”遮罩显示出来,我又给所有Marker统一设置了层级,选中的给999f,普通的给2f、3f之类的:
MarkerOptions selectedOptions = new MarkerOptions() .position(selectedLatLng) .icon(BitmapDescriptorFactory.fromBitmap(selectedIcon)) .zIndex(999f) .anchor(0.5f, 0.5f); aMap.addMarker(selectedOptions);线下在自己常用的Android设备上测试时,一切都符合预期:遮罩盖住了底图,选中的Marker清晰浮在遮罩上面,周围的普通Marker也被压到了遮罩下方。当时我还挺得意,觉得这个方案简单又高效,几行代码就搞定了“地图聚焦”效果。所以当线上反馈陆续过来的时候,我第一反应是“不可能吧”,但拿反馈里的机型一复现,问题就藏不住了。
1.2 复现测试的观察结果
我在测试设备上按线上反馈的路径重新走了一遍流程,把几种异常现象整理成了表格:
| 异常现象 | 触发条件 | 初步猜测 |
|---|---|---|
| 所选Marker被遮罩挡住一大半,像被啃了一口 | 部分中低端机型,地图处于默认视角 | 遮罩zIndex意外高于Marker? |
| 遮罩变成一条半透明灰带拖在Marker下方,整个矩形区域显示错乱 | 地图旋转或倾斜后 | GroundOverlay跟随地图旋转导致形状扭曲 |
| 选中Marker确实到了遮罩上面,但周围几个普通Marker也被一并带了出来 | 快速连续点击不同门店 | 普通Marker zIndex被某个选中机制错误覆盖 |
| 动态把某个Marker的zIndex从3f改成999f后,画面没有任何变化 | 多次切换选中门店后 | zIndex修改没有触发重绘 |
| 遮罩区域点击地图没反应,但点击遮罩外的地图可以拖动 | 部分Android版本上 | 事件被GroundOverlay拦截,与zIndex无关 |
这几种现象单独看都像是偶发Bug,但放到一起就会发现,光靠“把zIndex调大”解决不了任何一类问题。尤其是第二条——地图旋转后遮罩变成灰带拖在下面,这是GroundOverlay在地球表面上的固有表现:它本身是贴着地图经纬度坐标的矩形,视角一旦倾斜或者旋转,矩形就会在屏幕上变形、露边。这个现象和zIndex一点关系都没有,纯粹是方案选型错了。
但当时我没想明白这一点,而是把重心全放到了zIndex上,结果越深入越发现,高德SDK里zIndex的真实行为远比“数值越大越靠上”这句话复杂。
2. 高德SDK里zIndex的真实排序规则:官方说明与实践对不上
当我把问题聚焦到zIndex之后,第一件事是重新翻高德官方文档。文档里对zIndex的描述很简单,大意是:每个覆盖物都可以设置zIndex,数值越大显示越靠上。这句话本身没错,但它省掉了很多约束条件,而这些约束条件恰恰是问题的关键。
2.1 官方文档怎么说
高德Android SDK里,几乎所有覆盖物都支持setZIndex()方法,包括Marker、GroundOverlay、Polygon、Polyline、Circle、TileOverlay等。文档通篇都在强调“越大越靠上”,但实际使用中,“越靠上”这件事至少有三个隐含前提:
- 前提一:只在同一类覆盖物内部比较。多个Marker之间的zIndex可以互相比较,多个Polygon之间的zIndex也可以互相比较,但Marker和Polygon之间的层级关系,并不由zIndex决定。
- 前提二:zIndex相同时,后添加的覆盖物显示在上方。这个规则绝大多数情况下生效,但在Marker的Icon是异步加载的情况下,偶尔也会被打破。
- 前提三:修改zIndex是否立即生效,取决于引擎是否重新排序。实际测试中,动态
setZIndex()经常出现“调用成功但画面没变化”的情况。
文档没有把这三个前提写清楚,所以很多开发者会默认“zIndex是全局的层级开关”,我就是这么翻车的。
2.2 我自己实测下来的覆盖物层级关系
为了搞清楚各种覆盖物之间的真实层级,我干脆写了最小Demo,在地图上同时添加Marker、GroundOverlay、Polygon、Polyline、Text和TileOverlay,给它们设置不同的zIndex组合,挨个观察谁盖住谁。测了多轮之后,我得到了一张和直觉不太一样的表格:
| 覆盖物组合 | 实测层级结果 | 备注 |
|---|---|---|
| Marker vs Marker | zIndex大的在上面,同值时后添加的在上 | 基本可靠,但动态修改zIndex可能不刷新 |
| Marker vs Polygon | Marker永远在Polygon上面,与zIndex无关 | 即使Polygon设9999f也盖不住Marker |
| Marker vs GroundOverlay | Marker几乎总在GroundOverlay上面 | 但某些低端机上表现不稳定,会互相穿插 |
| Marker vs Text | Marker在Text上方 | zIndex对这两个类型无效 |
| Marker vs TileOverlay | Marker在TileOverlay上方 | 热力图盖不住Marker |
| Polygon vs Polygon | zIndex大的在上面 | 同类型排序相对可靠 |
| Polygon vs GroundOverlay | 取决于具体版本,大概率GroundOverlay在下方 | 不要依赖这个顺序 |
| GroundOverlay vs TileOverlay | TileOverlay在上方 | 两者都贴近底图 |
这里最核心的发现是:Marker和其他类型覆盖物之间的层级,基本是引擎写死的,zIndex只负责同一类型内部的排序。也就是说,我之前给Marker设999f、给GroundOverlay设0f,对跨类型的遮挡关系根本不起任何作用。
2.3 为什么同一类型内zIndex也偶尔失灵
按说既然zIndex对同类型覆盖物生效,那多Marker之间应该很稳定才对,但测试中我还是遇到了“zIndex明明是100f,却被一个zIndex只有2f的Marker盖住”的反常情况。排查后发现,这类问题往往出在Marker的图标加载时机上。
高德的BitmapDescriptor在内部是异步加载的。当你在很短的时间内连续add多个Marker,而这些Marker又都使用了BitmapDescriptorFactory.fromView()或fromBitmap()创建的图标时,每个Marker的图标纹理完成上传引擎的时间点并不一致。某些机型上引擎会按照纹理上传完成的顺序来排绘制批次,而不是按照zIndex排。后果就是:你明明给A设了更大的zIndex,但A的纹理加载慢,绘制时被排在后面,结果被B盖住。
此外,动态修改Marker的zIndex也有一层坑:marker.setZIndex(999f)调用后,如果引擎没有进入重新排序流程,画面上不会有任何变化。我测试过几种触发重绘的方式,最粗暴有效的是把Marker先remove掉再重新add一次,但这种方法会带来肉眼可见的闪烁,在连续切换选中门店时体验很糟。
这些测试做完,基本可以下结论:遮罩方案如果依赖zIndex来和Marker做层级对抗,从一开始就是错的。真正要解决的是“遮罩和Marker不该在同一层渲染”这件事。
3. 一次完整的排查链路:从业务代码怀疑到最小Demo定位
排查过程真正走下来,其实可以总结成非常清晰的几步。如果你也遇到了类似的遮罩穿透或盖不住的问题,可以按下面这套思路复现一遍排查逻辑,比直接搜网上的碎片答案要高效得多。
3.1 第一步:排除业务层的弱智错误
遇到层级异常,先别急着怀疑SDK,先确定自己的数值设置有没有被别的地方覆盖。我在业务代码里做过很多次这种蠢事:某个工具类里统一对Marker做了setZIndex(0f),或是某个异步回调又把Marker的Icon重新设置了一遍,导致zIndex被顺手重置。
所以第一步我会在添加完遮罩和Marker后,把两者的zIndex都打日志确认:
Log.d("LayerDebug", "overlay zIndex=" + maskOverlay.getZIndex()); Log.d("LayerDebug", "marker zIndex=" + selectedMarker.getZIndex());如果打印出来的数值符合预期,再进入下一步。数值都不对的话,先查业务层赋值。
3.2 第二步:加大遮罩zIndex“反压”Marker
确认数值没问题之后,我做了第一次反向测试:既然文档说zIndex越大越靠上,那我不让Marker去压遮罩,反过来让遮罩去压Marker,把GroundOverlay的zIndex改成9999f,Marker保持999f,看看遮罩能不能盖住Marker。
结果很直接——遮罩依然盖不住Marker,画面上Marker还是稳稳地浮在遮罩上面。接着我又试了把Marker的zIndex改成-9999f,想看看低到极端的值能不能让Marker被遮罩压下去,结果Marker照样在遮罩上面,完全无视这个数值。
这一步已经能证明:Marker和GroundOverlay之间的遮挡顺序,与zIndex没有线性关系,或者说它们根本不在同一个排序集合里。但为了确认这不是GroundOverlay特有的问题,我又继续换类型测试。
3.3 第三步:最小Demo固定排序
我把业务代码全部剥掉,只留一个MapView、一个GroundOverlay、一个Polygon和一个Marker,在页面加载后固定添加这些覆盖物,并分别设置不同的zIndex组合。测试结果十分稳定:
- 当遮罩是GroundOverlay,Marker始终在遮罩上方;
- 当遮罩改成Polygon,Marker依然在Polygon上方;
- 把Polygon的zIndex设成比Marker大100倍,Pattern不变;
- 反过来把Marker的zIndex设成比Polygon小,Pattern依然不变。
最小Demo的意义在于,把所有业务变量都剔除后,你能确认这是SDK的固定行为,而不是某段业务代码的偶然影响。到了这一步,我基本确定结论了:高德SDK里,Marker是地图上最高优先级的覆盖物之一,其他覆盖物想通过zIndex压在Marker上面,在多数版本里是行不通的。
3.4 第四步:验证动态修改zIndex的效果
既然遮罩压不住Marker,那“同类型Marker之间的动态排序”总该没问题吧?我顺手做了个测试:先添加A、B、C三个Marker,zIndex分别是1f、2f、3f,画面上C在最上。然后调用a.setZIndex(100f),结果画面上A并没有跑到最上面;调用b.setZIndex(100f)也一样。
只有把Marker移除再重新添加,顺序才会改变。这个发现对后续设计“选中门店置顶”功能非常重要——你不能指望一次setZIndex()就让Marker实时置顶,必须用“重建Marker”的方式强制引擎重新排序,或者从一开始就规划好固定zIndex。
排查链路走完,解决方案也就清楚了:与其在地图覆盖物层面和Marker抢层级,不如把遮罩层彻底移出地图引擎,放到View层级去处理。View永远高于地图上所有的Marker和Overlay,而且不依赖任何SDK的排序规则。
4. 落地修复:把遮罩从地图Overlay迁移到View层
最终修复方案分成了三块:全屏View遮罩、打孔露出选中Marker、处理事件透传。下面这版方案在我的实际项目和后续几个项目里都验证过,稳定性和兼容性都远胜GroundOverlay方案。
4.1 推荐做法:全屏View遮罩,天然盖住所有地图覆盖物
先把之前所有GroundOverlay相关的代码删掉,改成一个布局层级:
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.amap.api.maps.MapView android:id="@+id/map_view" android:layout_width="match_parent" android:layout_height="match_parent" /> <com.example.mask.MaskView android:id="@+id/mask_view" android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="true" android:focusable="true" /> </FrameLayout>MaskView放在MapView的上面,天然覆盖整个地图区域。不管地图里有多少Marker,也不管Marker的zIndex设成了什么值,都改变不了“View层在地图层之上”这个事实。
但这样直接铺上去,会把所有Marker全盖住。业务要求是选中的那个门店还能看清,所以需要MaskView在onDraw里给选中Marker的屏幕位置“打个孔”,让这个位置变成透明的:
public class MaskView extends View { private Paint holePaint; private float holeX, holeY, holeRadius; public MaskView(Context context) { super(context); // 关键:软渲染避免PorterDuff CLEAR在部分设备硬件加速下失效 setLayerType(LAYER_TYPE_SOFTWARE, null); holePaint = new Paint(Paint.ANTI_ALIAS_FLAG); holePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 画一层半透明深色遮罩 canvas.drawColor(Color.argb(190, 17, 20, 28)); // 在选中Marker的位置扣掉一个透明圆 if (holeRadius > 0) { canvas.drawCircle(holeX, holeY, holeRadius, holePaint); } } public void updateHole(float x, float y, float radius) { holeX = x; holeY = y; holeRadius = radius; invalidate(); } }这里有几个细节必须提:
PorterDuff.Mode.CLEAR在硬件加速下可能不生效。我一开始没有设置LAYER_TYPE_SOFTWARE,结果在部分设备上整个遮罩变成全黑,圆孔完全出不来。加上软件渲染后问题消失。这个坑很隐蔽,先写在这里。- 打孔位置必须实时同步。地图一移动,Marker的屏幕坐标就变了,所以要在相机监听回调里更新孔位。
- 孔半径要兼顾视觉效果和点击热区。我一般会用
dp2px(28f)左右,既能看清Marker,又不会因为孔太大导致遮罩形同虚设。
4.2 地图相机变化时同步孔位和Marker位置
打孔位置的更新,思路是监听地图相机变化,用Projection把经纬度转成屏幕坐标:
aMap.setOnCameraChangeListener(new AMap.OnCameraChangeListener() { @Override public void onCameraChange(CameraPosition cameraPosition) { // 持续变化时可以先不处理,避免频繁刷新 } @Override public void onCameraChangeFinish(CameraPosition cameraPosition) { Projection projection = aMap.getProjection(); Point point = projection.toScreenLocation(selectedLatLng); maskView.updateHole(point.x, point.y, dp2px(28f)); } });这里有个经验:onCameraChange回调在拖动地图时频率非常高,如果每次都做坐标转换和invalidate(),在低端机上会有掉帧感。所以我把同步操作放到了onCameraChangeFinish,等于地图停下来之后再补一帧,体验会顺滑很多。如果你希望拖动过程中孔也一直跟着Marker走,可以折中一下,在onCameraChange里只更新坐标不刷新视图,等Finish里再统一重绘。
4.3 如果需要Marker真正显示在遮罩之上:镜像Marker到View层
打孔方案解决的是“从孔里透出Marker”的需求。但如果业务上就是要求Marker图标真的盖在遮罩最上面,再配合阴影、光晕等效果,那打孔方案就不够用了。
这种情况下我的做法是:把地图里被选中的那个Marker隐藏掉,在地图的顶层View(甚至直接在MaskView的兄弟层)放一个透明View,自己把这个Marker的图标画出来。坐标同步逻辑和上面一模一样,只是把updateHole换成了“更新镜像Marker位置”:
public class MirrorMarkerView extends View { private Point screenPoint; private Bitmap markerBitmap; public void update(Point point, Bitmap bitmap) { screenPoint = point; markerBitmap = bitmap; invalidate(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (screenPoint != null && markerBitmap != null) { canvas.drawBitmap(markerBitmap, screenPoint.x - markerBitmap.getWidth() / 2f, screenPoint.y - markerBitmap.getHeight() / 2f, null); } } }布局上,MirrorMarkerView放在MaskView的上面:
<com.example.mask.MaskView ... /> <com.example.mask.MirrorMarkerView android:id="@+id/mirror_view" android:layout_width="match_parent" android:layout_height="match_parent" />这样层级就是:MapView在最底,MaskView盖上去,MirrorMarkerView在最顶。选中的Marker图标永远清晰可见,而且完全不受地图SDK的内部排序影响。代价是需要自己管理这个Marker的图标缩放、锚点偏移和隐藏逻辑,适合对视觉效果要求高、且只是单点高亮的场景。
4.4 点击事件穿透:让遮罩外的点击落在地图上,孔内点击落在Marker上
遮罩铺上去之后,还有个问题不能忽略:整个MaskView如果拦截了所有触摸事件,用户就没办法拖动地图了。解决办法是在MaskView的onTouchEvent里区分孔内孔外:
@Override public boolean onTouchEvent(MotionEvent event) { float x = event.getX(); float y = event.getY(); boolean insideHole = isInsideHole(x, y); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mBlockTouch = !insideHole; return mBlockTouch; // 孔内返回false,事件继续交给下层MapView case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_UP: return mBlockTouch; default: return false; } } private boolean isInsideHole(float x, float y) { float dx = x - holeX; float dy = y - holeY; return dx * dx + dy * dy <= holeRadius * holeRadius; }这里有个Android事件分发的机制要弄清楚:ACTION_DOWN返回false,说明这个View不消费事件,那么本次触摸序列的后续MOVE、UP事件都不会再传给MaskView,而是直接交还给父容器继续寻找能消费的下层View,也就是MapView自己来处理。这样就能做到“点击遮罩区域时拦截,点击孔内区域时正常控制地图、正常点击Marker”。
如果你还想在打孔区域内手动触发Marker点击,可以结合高德的Projection.fromScreenLocation(),把触摸坐标转回经纬度,再和选中Marker做距离判断,手动走一遍业务点击逻辑。但这个属于进阶定制了,大多数场景用不到。
5. 同类问题避坑清单:zIndex在这些场景到底该不该用
修复完这个项目的问题之后,我又把高德SDK相关的层级需求整体梳理了一遍。下面这几类场景是我觉得容易混淆、也容易再次踩坑的地方,提前排好雷比到时候再排查高效得多。
5.1 多Marker置顶排序,zIndex的正确用法
如果你的需求只是“同类Marker之间谁显示在上面”,那zIndex是可以用的,但必须注意三点:
- 给重要Marker设置一个固定的、明显大于其他Marker的zIndex值。不要用2f、3f这种小数值,很容易和别的Marker撞值导致顺序不确定。我一般会给普通Marker用10f、20f、30f递增,给重要Marker用1000f以上。
- 不要依赖动态
setZIndex()。前面测过,动态修改在部分版本上不触发重排。如果要切换选中状态,最稳定的做法是把旧Marker的setVisible(false),再重新add一个高zIndex的新Marker,或者直接remove后重建。 - 同一时间只允许一个“置顶Marker”存在。如果业务上需要连续高亮多个点,先恢复前一个Marker的zIndex,再对新的Marker执行置顶操作。避免多个高zIndex Marker同时存在,否则排序又变得不可控。
5.2 Marker与InfoWindow的层级关系
InfoWindow是挂在Marker上的,经验值来看它的渲染层级高于所有覆盖物,哪怕Marker本身的zIndex比较低,弹出的InfoWindow也能正常显示。但有一种情况会出现意外:当你通过marker.setZIndex()或marker.remove()切换Marker时,旧Marker的InfoWindow可能短暂残留或闪烁,尤其是在低端机上。
建议做法:切换选中状态前,先对旧Marker调用hideInfoWindow(),再执行下一步操作,能有效避免“弹窗挂在新Marker上但内容还是旧的”这种诡异Bug。另外,如果使用前面说的View层遮罩,InfoWindow也会被遮罩盖住,因为它毕竟是地图SDK内部的层级。需要让气泡也露出来时,直接关闭SDK的InfoWindow,在MirrorMarkerView里自己画气泡,反而更可控。
5.3 GroundOverlay和TileOverlay做UI遮罩,尽量别用
GroundOverlay是贴在地球表面的,地图一旋转、一倾斜,矩形就会变形漏边。TileOverlay主要用来做热力图、自定义瓦片,不太适合做全屏遮罩类UI,而且它和Marker的层级关系在不同SDK版本里表现不一致。
凡是“全屏暗化、高亮、引导、蒙层”这类需求,都优先考虑View层解决。地图Overlay只适合做和地理坐标强绑定的东西,比如规划路线、区域边界、热力范围,这些本来就是经纬度对应的内容,用Overlay是合理的;但UI半透明蒙层这种纯屏幕效果,放到View层才是它该待的地方。
5.4 真机验证时的两个固定测试项
最后补充两个我在这个项目之后养成的习惯。所有涉及Marker层级的改动,至少要在一台低端机(内存小、GPU老旧)和一台新设备上各跑一遍。低端机上Marker的纹理加载慢,最容易暴露出“zIndex值明明更大却被盖住”的问题。另外一定要手动旋转地图、倾斜视角各测一次,很多层级问题只在相机视角变化后才暴露出来,固定视角下怎么看都是正常的。
当初那个GroundOverlay方案要是刚做完就转一下地图,可能当天就能发现遮罩变形漏边的隐患,不至于拖到线上反馈。吃一堑长一智,现在我做地图功能的第一反应永远是:先把需求拆成“地理层”和“屏幕层”,再决定用Overlay还是用View。高德SDK里的zIndex是同类覆盖物的排序参数,不是跨类型的绝对层级;遮罩这种需求,交给View层,地图图层只负责地理数据,这是那次问题给我留下最深刻的一条经验。