- 示例工程
- 教程
【免费下载链接】cw-omnibus
Source code to omnibus edition of _The Busy Coder's Guide to Android Development_
本指南以 cw-omnibus 仓库中 MapsV2/ImagePopups/README.markdown 所描述的示例应用为主线,完整解析如何在 Google Maps V2(play-services-maps)的 Marker 信息窗口(Info Window)中异步加载并展示网络图片。读者将掌握InfoWindowAdapter的自定义实现、Picasso 异步图片加载与信息窗口内容刷新的配合方式,以及 Maps V2 应用必备的 Google Play 服务兼容性检查流程,可直接将这套模式复用到自己的地图类应用中。
一、示例应用要解决什么问题
官方文档对 ImagePopups 的定位只有一句话:演示包含下载图片的信息窗口("info windows containing downloaded images")。其背后对应的是 Maps V2 开发中一个常见且棘手的需求——默认的 Marker 信息窗口只支持标题(title)和摘要(snippet)两行文字,无法直接渲染图片、自定义布局。
本示例正是围绕这一点展开:通过实现GoogleMap.InfoWindowAdapter接口,把信息窗口的内容替换为自定义视图,并在该视图中使用 Picasso 异步加载存放在网络上的建筑照片,同时处理图片尚未加载完成时信息窗口已绘制成快照的问题。全部源码位于 MapsV2/ImagePopups/app/src/main 下,共三个 Java 类与若干布局、资源文件,结构非常精简,适合作为学习起点。
1.1 项目结构与运行环境
| 文件 | 作用 |
|---|---|
| MainActivity.java | 地图初始化、添加 Marker、注册信息窗口适配器 |
| PopupAdapter.java | 实现InfoWindowAdapter,用 Picasso 异步加载图片 |
| AbstractMapActivity.java | Google Play 服务可用性检查基类,可复用 |
| activity_main.xml | 仅含一个SupportMapFragment |
| popup.xml | 信息窗口自定义布局(图片 + 标题 + 摘要) |
| AndroidManifest.xml | 权限、API Key 与 OpenGL ES 特性声明 |
| app/build.gradle | 依赖与构建配置 |
从 app/build.gradle 可见该示例的技术栈:
dependencies { implementation 'com.android.support:support-v4:27.1.1' implementation 'com.google.android.gms:play-services-maps:15.0.1' implementation 'com.squareup.picasso:picasso:2.5.2' } android { compileSdkVersion 27 buildToolsVersion '27.0.3' defaultConfig { minSdkVersion 14 targetSdkVersion 27 } }- minSdkVersion 14:覆盖 Android 4.0 及以上设备;
- play-services-maps 15.0.1:Maps V2 的 Google Play 服务组件,提供
GoogleMap、SupportMapFragment等核心 API; - Picasso 2.5.2:Square 的图片加载库,负责网络图片的异步拉取、缩放与缓存;
- support-v4 27.1.1:提供
FragmentActivity与SupportMapFragment的兼容支持。
另外仓库还保留了一份旧式 Ant 构建的 project.properties(target=android-19,并引用google-play-services_lib),仅用于兼容早期构建流程,实际开发建议以 Gradle 配置为准。
1.2 运行前提
运行本示例需要满足:
- 设备/模拟器上已安装可用的 Google Play 服务(Maps V2 依赖其运行);
- 具备 OpenGL ES 2.0 及以上能力(
AndroidManifest.xml中通过uses-feature声明); - 替换 AndroidManifest.xml 中的
com.google.android.maps.v2.API_KEY为你自己在 Google Cloud Console 申请的地图密钥(示例中硬编码的AIzaSyC4iyT46cB00IdKGcy5EmAxK5uCOQX2Oy8仅为示例代码的演示密钥,正式发布前必须更换)。
二、自定义信息窗口:InfoWindowAdapter 的两种方法
Maps V2 为每个 Marker 提供默认信息窗口,仅能显示两行文本。要突破这个限制,需要调用map.setInfoWindowAdapter()注册适配器。PopupAdapter 实现了GoogleMap.InfoWindowAdapter接口:
class PopupAdapter implements InfoWindowAdapter { private View popup=null; private LayoutInflater inflater=null; private HashMap<String, Uri> images=null; private Context ctxt=null; private int iconWidth=-1; private int iconHeight=-1; private Marker lastMarker=null; PopupAdapter(Context ctxt, LayoutInflater inflater, HashMap<String, Uri> images) { ... iconWidth = ctxt.getResources().getDimensionPixelSize(R.dimen.icon_width); iconHeight = ctxt.getResources().getDimensionPixelSize(R.dimen.icon_height); } @Override public View getInfoWindow(Marker marker) { return(null); } @Override public View getInfoContents(Marker marker) { ... } }接口中的两个方法分工明确:
| 方法 | 返回值语义 |
|---|---|
getInfoWindow(Marker) | 返回整个信息窗口(含边框、背景)的自定义视图;返回null表示沿用地图默认的窗口外观 |
getInfoContents(Marker) | 仅替换默认窗口内部的内容区域,外观(圆角背景、小尾巴箭头)仍由地图引擎绘制 |
本示例的策略是getInfoWindow返回null、getInfoContents返回自定义布局——这样可以保留信息窗口原生的小尾巴与圆角阴影,同时自由定制内部排版。PopupAdapter在构造时即从 dimens.xml 读取图片的目标尺寸:
<dimen name="icon_width">96dp</dimen> <dimen name="icon_height">64dp</dimen>这两个值会用于后续 Picasso 的resize(iconWidth, iconHeight)调用,避免直接把原图塞进信息窗口导致布局溢出。
三、MainActivity:Marker 与图片 URL 的映射
MainActivity 继承AbstractMapActivity(FragmentActivity子类),同时实现OnMapReadyCallback与OnInfoWindowClickListener。布局文件 activity_main.xml 只有一个全屏的SupportMapFragment:
<fragment xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/map" android:layout_width="match_parent" android:layout_height="match_parent" class="com.google.android.gms.maps.SupportMapFragment"/>在onCreate中通过mapFrag.getMapAsync(this)异步获取GoogleMap实例(API 15.0.1 的标准用法),并用savedInstanceState == null判断是否为首次进入,从而决定是否需要初始化相机位置。
3.1 相机初始化与 Marker 添加
onMapReady回调中,MainActivity.java 先把地图定位到纽约曼哈顿(约 40.7679, -73.9818),缩放到 15 级,然后依次添加四个地标 Marker:
| 地标 | 标题/摘要(来自 strings.xml) | 关联图片 |
|---|---|---|
| 联合国总部 | UN / United Nations | UN_HQ.jpg |
| 林肯中心 | Lincoln Center / Home of Jazz at Lincoln Center | Avery_Fisher_Hall.jpg |
| 卡内基音乐厅 | Carnegie Hall / Where you go with practice, practice, practice | Carnegie_Hall.jpg |
| 市中心俱乐部 | The Downtown Club / Original home of the Heisman Trophy | 无图片 |
每个 Marker 由addMarker()帮助方法创建(MainActivity.java):
private void addMarker(GoogleMap map, double lat, double lon, int title, int snippet, String image) { Marker marker= map.addMarker(new MarkerOptions().position(new LatLng(lat, lon)) .title(getString(title)) .snippet(getString(snippet))); if (image != null) { images.put(marker.getId(), Uri.parse("http://misc.commonsware.com/mapsv2/" + image)); } }这里有一个贯穿全示例的关键设计:用marker.getId()作为 key,把 Marker 与其网络图片 URL 建立映射,存入HashMap<String, Uri> images。因为InfoWindowAdapter的每个回调都只携带Marker对象,适配器正是依靠marker.getId()从这张映射表里反查图片地址。图片统一存放在http://misc.commonsware.com/mapsv2/目录下,文件名与地标一一对应。
3.2 注册适配器与点击监听
map.setInfoWindowAdapter(new PopupAdapter(this, getLayoutInflater(), images)); map.setOnInfoWindowClickListener(this);setInfoWindowAdapter()让地图引擎在每次弹出信息窗口时回调PopupAdapter;setOnInfoWindowClickListener()让窗口本体可点击——示例中onInfoWindowClick只是用Toast弹出 Marker 标题(MainActivity.java),实战中可替换为跳转详情页等业务逻辑。
四、PopupAdapter 核心:异步图片加载的完整套路
getInfoContents的实现(PopupAdapter.java)是整份示例的技术核心,包含了三个值得反复品味的细节。
4.1 布局只 inflate 一次,视图复用
if (popup == null) { popup=inflater.inflate(R.layout.popup, null); }信息窗口的弹出和关闭非常频繁,每次回调都重新inflate会带来不必要的对象创建开销。这里把popup.xml的布局实例缓存为成员变量,首次使用时才加载,后续直接复用同一个视图。
4.2 用 lastMarker 区分"切换 Marker"与"重复刷新"
if (lastMarker == null || !lastMarker.getId().equals(marker.getId())) { lastMarker=marker; // 更新标题、摘要,加载图片 ... }只有切换到不同的 Marker 时才重新绑定文本、重新加载图片。同一个 Marker 的多次回调直接复用已填充好的视图,既省去重复的setText与图片请求,也避免了图片闪烁。
4.3 Picasso 链式加载与信息窗口刷新
Uri image=images.get(marker.getId()); ImageView icon=(ImageView)popup.findViewById(R.id.icon); if (image == null) { icon.setVisibility(View.GONE); // 无图 Marker 隐藏 ImageView } else { icon.setVisibility(View.VISIBLE); Picasso.with(ctxt).load(image).resize(iconWidth, iconHeight) .centerCrop().noFade() .placeholder(R.drawable.placeholder) .into(icon, new MarkerCallback(marker)); }这条链式调用值得逐项拆解:
Picasso.with(ctxt):Picasso 2.x 的静态入口,返回单例实例;.load(image):接受Uri,此处即images映射表里的网络地址;.resize(iconWidth, iconHeight):把图片缩放到 dimens.xml 定义的 96×64dp,避免大图直接绘制到信息窗口;.centerCrop():按目标尺寸等比裁剪居中,保证缩放后填满整个 ImageView 且不变形;.noFade():禁用 Picasso 默认的淡入动画,让图片更快地"就位",减少信息窗口内容重绘时的违和感;.placeholder(R.drawable.placeholder):加载期间先显示占位图(drawable-mdpi下的 placeholder.png,96×64,与本示例的 icon 尺寸一致),避免空白;.into(icon, callback):加载完成/失败后回调第二个参数。
4.4 加载完成后强制刷新信息窗口
MarkerCallback(PopupAdapter.java)是整套方案中最容易踩坑、也最需要理解的一环:
static class MarkerCallback implements Callback { Marker marker=null; MarkerCallback(Marker marker) { this.marker=marker; } @Override public void onError() { Log.e(getClass().getSimpleName(), "Error loading thumbnail!"); } @Override public void onSuccess() { if (marker != null && marker.isInfoWindowShown()) { marker.showInfoWindow(); } } }背景知识:Maps V2 的信息窗口不是真正的View,而是地图引擎把getInfoContents返回的视图**离线渲染成一帧位图(bitmap)**再贴到地图上。这意味着:
- 图片异步加载完成时,之前绘制到地图上的信息窗口快照不会自动更新;
- 因此必须在
onSuccess回调里手动调用marker.showInfoWindow(),强制引擎重新走一遍getInfoContents,用已就绪的图片重新渲染窗口; - 调用前用
marker.isInfoWindowShown()判断窗口当前是否仍处于显示状态,避免"用户已关闭窗口却仍被强制重开"的异常行为; - 图片加载失败(如网络异常)时,
onError只记录日志,此时占位图或已缓存的旧图会保留在窗口中。
这个"异步回调 +showInfoWindow()重新渲染"的模式,是所有需要在信息窗口中展示异步内容(图片、动态文本等)的 Maps V2 应用的通用解法。
五、popup.xml:信息窗口的布局设计
自定义内容布局 popup.xml 采用水平LinearLayout:
<LinearLayout ... android:orientation="horizontal"> <ImageView android:id="@+id/icon" android:layout_width="@dimen/icon_width" android:layout_height="@dimen/icon_height" android:padding="2dip" android:src="@drawable/ic_launcher" android:contentDescription="@string/icon"/> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:orientation="vertical"> <TextView android:id="@+id/title" ... android:textSize="25sp" android:textStyle="bold"/> <TextView android:id="@+id/snippet" ... android:textSize="15sp"/> </LinearLayout> </LinearLayout>要点:
- 左侧
ImageView(@+id/icon)承载异步加载的建筑照片,尺寸锁定为icon_width/icon_height(96×64dp),带 2dip 内边距; android:src="@drawable/ic_launcher"作为 XML 层面的兜底占位,运行时 Picasso 会覆盖它;- 右侧垂直
LinearLayout依次排列@+id/title(25sp 加粗)与@+id/snippet(15sp),与 PopupAdapter.java 中setText绑定的控件一一对应; - 由于无图的 Marker(如 The Downtown Club)需要隐藏图片,代码通过
icon.setVisibility(View.GONE)处理,布局中无需额外分支。
六、AbstractMapActivity:Maps V2 的"上岗体检"
Maps V2 依赖 Google Play 服务与 OpenGL ES 2.0,二者缺一不可,因此所有 MapsV2 系列示例共用基类 AbstractMapActivity.java 做启动前的兼容性检查,MainActivity.onCreate中if (readyToGo())即以此为前提。
readyToGo()的检查逻辑分三层:
- Google Play 服务可用性:调用
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this),返回ConnectionResult.SUCCESS才继续; - 错误可修复:若返回其他状态码,用
checker.isUserResolvableError(status)判断用户能否通过更新商店应用等方式解决;能解决则弹出ErrorDialogFragment(内部调用checker.getErrorDialog()生成系统对话框),并在对话框关闭时finish()当前 Activity; - 不可修复:直接
Toast提示 "Google Maps V2 not available!" 并关闭页面。
除此之外,该类还复用了 Android CTS 测试中提取的getVersionFromPackageManager()方法(AbstractMapActivity.java),通过PackageManager.getSystemAvailableFeatures()找到name == null的 OpenGL ES 特性条目,读取reqGlEsVersion的高 16 位得到主版本号,要求必须 ≥ 2(即 OpenGL ES 2.0)。这条检查与 AndroidManifest.xml 中的声明相互印证:
<uses-feature android:glEsVersion="0x00020000" android:required="false"/>android:required="false"表示应用不强制要求设备具备 OpenGL ES 2.0(这样设备仍可安装),真正的拦截由readyToGo()在运行时完成。
七、Manifest 与构建配置细节
7.1 清单文件要点
AndroidManifest.xml 除前面提到的 API Key 与uses-feature外,还有两处值得注意:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="22" />WRITE_EXTERNAL_STORAGE加上maxSdkVersion="22":仅在 Android 6.0(API 23)之前的系统上申请写外部存储权限。这是为兼容旧版本 Picasso 等图片库把缓存写入外部存储的旧行为,Android 6.0+ 后图片库默认使用内部缓存目录,不再需要该权限;- 应用内还需声明
com.google.android.gms.version的meta-data,其值为@integer/google_play_services_version,用于校验设备上 Google Play 服务版本是否与应用匹配。
7.2 构建方式
- Gradle 构建:以 MapsV2/ImagePopups/build.gradle 为顶层脚本(AGP 3.1.4),配合 app/build.gradle 即可用 Android Studio 直接打开
MapsV2/ImagePopups目录运行; - 旧式构建:project.properties 中通过
android.library.reference.1引用本机 SDK 下的google-play-services_lib,属 Ant 时代的兼容残留,仅作参考。
八、可复用的实战要点总结
把本示例提炼成可直接套用的工程模式:
- 信息窗口定制:
getInfoWindow返回null保留原生窗口外观,getInfoContents返回自定义布局,兼顾美观与自由排版; - 图片定位:用
marker.getId()作为 key 维护"Marker → 图片 URL"映射表,适配器回调时反查,解耦 Marker 与图片数据; - 异步刷新:Picasso 等图片库的加载是异步的,而信息窗口是位图快照——务必在加载完成的回调中调用
marker.showInfoWindow()并配合isInfoWindowShown()判断,否则图片永远不会显示; - 视图复用:布局缓存 +
lastMarker判断,减少重复 inflate 与重复网络请求; - 尺寸控制:
resize+centerCrop把任意尺寸的网络图片约束到固定尺寸(96×64dp),配合placeholder占位图避免加载期空白; - 启动体检:复用 AbstractMapActivity 的
readyToGo()逻辑,处理 Google Play 服务缺失与 OpenGL ES 版本不足的设备。
若想对比"纯文字信息窗口"与"自定义图片信息窗口"的差异,可同时阅读同目录下的 MapsV2/Popups 示例;ImagePopups 本身则提供了从文字到图文信息窗口升级的最短路径。需要说明的是,示例中的图片托管于http://misc.commonsware.com/mapsv2/,演示时依赖该站点可访问;在生产项目中,应将图片资源替换为自己的 CDN 或本地资源,并更换 AndroidManifest.xml 中的 Maps API Key。
- 示例工程
- 教程
【免费下载链接】cw-omnibus
Source code to omnibus edition of _The Busy Coder's Guide to Android Development_
相关推荐
老电脑装 Windows 11 总是被拦?用 FlyOOBE 10 分钟搞定完整升级
老电脑装 Windows 11 总是被拦?用 FlyOOBE 10 分钟搞定完整升级 还在因为"这台电脑不满足 Windows 11 系统要求"的提示而卡在升级
示例工程教程novelWriter 场景文档格式深度解析:从示例项目 "Alice getting very tired" 看小说文档的存储与标记规范
novelWriter 场景文档格式深度解析:从示例项目 "Alice getting very tired" 看小说文档的存储与标记规范 本篇技术指南以 no
示例工程教程cw-omnibus MapsV2/Taps 实战指南:Google Maps V2 标记(Marker)点击检测与信息窗定制
cw omnibus MapsV2/Taps 实战指南:Google Maps V2 标记(Marker)点击检测与信息窗定制 导读 本文以 cw omnibu
示例工程教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考