Google Android 的 Maps SDK:将相机平滑地动画到新位置,沿途渲染所有图块
Google Maps SDK for Android: Smoothly animating the camera to a new location, rendering all the tiles along the way
背景
之前似乎有人在 SO 上问过许多类似的问题(最著名的是 android google maps not loading the map when using GoogleMap.AnimateCamera() and How can I smoothly pan a GoogleMap in Android?),但是 none 在这些线程中发布的答案或评论让我对如何这样做。
我最初认为它会像调用 animateCamera(CameraUpdateFactory.newLatLng(), duration, callback)
一样简单,但就像上面第一个 link 的 OP 一样,在动画完成之前我得到的只是一个灰色或非常模糊的地图,哪怕我把它慢到几十秒!
我设法找到并实现了 this helper class,它可以很好地让图块沿途呈现,但即使延迟为 0,每个动画之间也有明显的滞后。
代码
好的,是时候写一些代码了。这是(稍微修改过的)助手 class:
package com.coopmeisterfresh.googlemaps.NativeModules;
import android.os.Handler;
import com.google.android.gms.maps.CameraUpdate;
import com.google.android.gms.maps.GoogleMap;
import java.util.ArrayList;
import java.util.List;
public class CameraUpdateAnimator implements GoogleMap.OnCameraIdleListener {
private final GoogleMap mMap;
private final GoogleMap.OnCameraIdleListener mOnCameraIdleListener;
private final List<Animation> cameraUpdates = new ArrayList<>();
public CameraUpdateAnimator(GoogleMap map, GoogleMap.
OnCameraIdleListener onCameraIdleListener) {
mMap = map;
mOnCameraIdleListener = onCameraIdleListener;
}
public void add(CameraUpdate cameraUpdate, boolean animate, long delay) {
if (cameraUpdate != null) {
cameraUpdates.add(new Animation(cameraUpdate, animate, delay));
}
}
public void clear() {
cameraUpdates.clear();
}
public void execute() {
mMap.setOnCameraIdleListener(this);
executeNext();
}
private void executeNext() {
if (cameraUpdates.isEmpty()) {
mOnCameraIdleListener.onCameraIdle();
} else {
final Animation animation = cameraUpdates.remove(0);
new Handler().postDelayed(() -> {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}, animation.mDelay);
}
}
@Override
public void onCameraIdle() {
executeNext();
}
private static class Animation {
private final CameraUpdate mCameraUpdate;
private final boolean mAnimate;
private final long mDelay;
public Animation(CameraUpdate cameraUpdate, boolean animate, long delay) {
mCameraUpdate = cameraUpdate;
mAnimate = animate;
mDelay = delay;
}
}
}
我的实现代码:
// This is actually a React Native Component class, but I doubt that should matter...?
public class NativeGoogleMap extends SimpleViewManager<MapView> implements
OnMapReadyCallback, OnRequestPermissionsResultCallback {
// ...Other unrelated methods removed for brevity
private void animateCameraToPosition(LatLng targetLatLng, float targetZoom) {
// googleMap is my GoogleMap instance variable; it
// gets properly initialised in another class method
CameraPosition currPosition = googleMap.getCameraPosition();
LatLng currLatLng = currPosition.target;
float currZoom = currPosition.zoom;
double latDelta = targetLatLng.latitude - currLatLng.latitude;
double lngDelta = targetLatLng.longitude - currLatLng.longitude;
double latInc = latDelta / 5;
double lngInc = lngDelta / 5;
float zoomInc = 0;
float minZoom = googleMap.getMinZoomLevel();
float maxZoom = googleMap.getMaxZoomLevel();
if (lngInc > 15 && currZoom > minZoom) {
zoomInc = (minZoom - currZoom) / 5;
}
CameraUpdateAnimator animator = new CameraUpdateAnimator(googleMap,
() -> googleMap.animateCamera(CameraUpdateFactory.zoomTo(
targetZoom), 5000, null));
for (double nextLat = currLatLng.latitude, nextLng = currLatLng.
longitude, nextZoom = currZoom; Math.abs(nextLng) < Math.abs(
targetLatLng.longitude);) {
nextLat += latInc;
nextLng += lngInc;
nextZoom += zoomInc;
animator.add(CameraUpdateFactory.newLatLngZoom(new
LatLng(nextLat, nextLng), (float)nextZoom), true);
}
animator.execute();
}
}
问题
有没有更好的方法来完成这个看似简单的任务?我在想,也许我需要将我的动画移动到工作线程或其他东西;会有帮助吗?
感谢阅读(我知道这是一个努力 :P)!
2021 年 9 月 30 日更新
我已经根据 Andy 在评论中的建议更新了上面的代码,虽然它可以工作(尽管有相同的滞后和渲染问题),但最终的算法需要更复杂一些,因为我想缩小到纵向三角洲的中途点,然后随着旅程的继续返回。
同时进行所有这些计算,以及平滑地渲染所有必要的图块同时,对于我的廉价手机 phone 来说似乎太多了正在测试。或者这是 API 本身的限制?在任何情况下,我怎样才能让所有这些工作顺利进行,在排队的动画之间没有任何延迟?
这是我使用你的实用框架播放器的尝试。
一些注意事项:
- 缩放值根据总步数(此处设置为 500)并给定起始值和停止值进行插值。
- Google 地图实用程序用于根据分数距离计算下一个经纬度:
SphericalUtil.interpolate
。
- 分数距离不应是线性函数,以减少引入新图块。换句话说,在更高的变焦(更近)时,相机移动的距离更短,并且相机移动量在缩小时呈指数增加(center-to-center)。这需要更多解释...
- 如您所见,遍历分为两部分 - 反转距离移动的指数函数。
- 最远的“最大”缩放(坏名)可以是总距离的函数 - 计算为在中点包含整个路径。现在,对于这种情况,它被硬编码为 4。
- 请注意地图
animate
函数 不能 被使用,因为它在每个步骤 上引入了自己的弹跳球效果 这是不受欢迎的。因此,给定一定数量的步骤,可以使用 move
函数。
- 此方法试图最大限度地减少每一步的图块加载,但最终 TileLoader 是无法(轻松)监控的查看限制因素。
animateCameraToPosition
// flag to control the animate callback (at completion).
boolean done = false;
private void animateCameraToPosition(LatLng targetLatLng, float targetZoom) {
CameraPosition currPosition = gMap.getCameraPosition();
LatLng currLatLng = currPosition.target;
//meters_per_pixel = 156543.03392 * Math.cos(latLng.lat() * Math.PI / 180) / Math.pow(2, zoom)
int maxSteps = 500;
// number of steps between start and midpoint and midpoint and end
int stepsMid = maxSteps / 2;
// current zoom
float initz = currPosition.zoom;
//TODO maximum zoom (can be computed from overall distance) such that entire path
// is visible at midpoint.
float maxz = 4.0f;
float finalz = targetZoom;
CameraUpdateAnimator animator = new CameraUpdateAnimator(gMap, () -> {
if (!done) {
gMap.animateCamera(CameraUpdateFactory.
zoomTo(targetZoom), 5000, null);
}
done = true;
});
// loop from start to midpoint
for (int i = 0; i < stepsMid; i++) {
// compute interpolated zoom (current --> max) (linear)
float z = initz - ((initz - maxz) / stepsMid) * i;
// Compute fractional distance using an exponential function such that for the first
// half the fraction delta advances slowly and accelerates toward midpoint.
double ff = (i * (Math.pow(2,maxz) / Math.pow(2,z))) / maxSteps;
LatLng nextLatLng =
SphericalUtil.interpolate(currLatLng, targetLatLng, ff);
animator.add(CameraUpdateFactory.newLatLngZoom(
nextLatLng, z), false, 0);
}
// loop from midpoint to final
for (int i = 0; i < stepsMid; i++) {
// compute interpolated zoom (current --> max) (linear)
float z = maxz + ((finalz - maxz) / stepsMid) * i;
double ff = (maxSteps - ((i+stepsMid) * ( (Math.pow(2,maxz) / Math.pow(2,z)) ))) / (double)maxSteps;
LatLng nextLatLng =
SphericalUtil.interpolate(currLatLng, targetLatLng, ff);
animator.add(CameraUpdateFactory.newLatLngZoom(
nextLatLng, z), false, 0);
}
animator.add(CameraUpdateFactory.newLatLngZoom(
targetLatLng, targetZoom), true, 0);
//
animator.execute();
}
测试代码
我用这两个点(和代码)从自由女神像到西海岸的一个点进行了测试:
gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(40.68924, -74.04454), 13.0f));
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
animateCameraToPosition(new LatLng(33.899832, -118.020450), 13.0f);
}
}, 5000);
CameraUpdateAnimator 模组
我稍微修改了相机更新动画:
public void execute() {
mMap.setOnCameraIdleListener(this);
executeNext();
}
private void executeNext() {
if (cameraUpdates.isEmpty()) {
mMap.setOnCameraIdleListener(mOnCameraIdleListener);
mOnCameraIdleListener.onCameraIdle();
} else {
final Animation animation = cameraUpdates.remove(0);
// This optimization is likely unnecessary since I think the
// postDelayed does the same on a delay of 0 - execute immediately.
if (animation.mDelay > 0) {
new Handler().postDelayed(() -> {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}, animation.mDelay);
} else {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}
}
}
采样前
正在使用
// assume initial (40.68924, -74.04454) z=13.0f
gMap.animateCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(33.899832,-118.020450), 13.0f), 30000, null);
采样后
这些是从模拟器录制的。我还侧载到我的 phone (Samsumg SM-G960U) 上,结果相似(使用 1000 步 0 延迟)。
所以我认为这并不完全符合你的要求:有一些“模糊的瓷砖”,因为它们是从西方引进的。
自由女神像 - 到 - 圣地亚哥附近某处
诊断
在某些方面了解地图对图块的处理方式很有用。可以通过安装一个简单的 UrlTileProvider
并记录请求来提供洞察力。此实现获取 google 个图块,尽管它们的分辨率通常较低。
为此,需要满足以下条件:
// Turn off this base map and install diagnostic tile provider
gMap.setMapType(GoogleMap.MAP_TYPE_NONE);
gMap.addTileOverlay(new TileOverlayOptions().tileProvider(new MyTileProvider(256,256)).fadeIn(true));
并定义诊断文件提供程序
public class MyTileProvider extends UrlTileProvider {
public MyTileProvider(int i, int i1) {
super(i, i1);
}
@Override
public URL getTileUrl(int x, int y, int zoom) {
Log.i("tiles","x="+x+" y="+y+" zoom="+zoom);
try {
return new URL("http://mt1.google.com/vt/lyrs=m&x="+x+"&y="+y+"&z="+zoom);
} catch (MalformedURLException e) {
e.printStackTrace();
return null;
}
}
}
您会立即注意到切片图层始终以整数单位 (int
) 定义。缩放中提供的分数缩放(例如 LatLngZoom
严格使用 in-memory 图像 - 很高兴知道。'
这里有一个完整的示例:
// initial zoom
x=2411 y=3080 zoom=13
x=2410 y=3080 zoom=13
x=2411 y=3081 zoom=13
x=2410 y=3081 zoom=13
x=2411 y=3079 zoom=13
x=2410 y=3079 zoom=13
最大:
x=9 y=12 zoom=5
x=8 y=12 zoom=5
x=9 y=11 zoom=5
x=8 y=11 zoom=5
x=8 y=13 zoom=5
x=9 y=13 zoom=5
x=7 y=12 zoom=5
x=7 y=11 zoom=5
x=7 y=13 zoom=5
x=8 y=10 zoom=5
x=9 y=10 zoom=5
x=7 y=10 zoom=5
这是每次调用平铺程序 (x-axis) 时的缩放 (y-axis) 图表。每个缩放层 大致 相同的数量,这是我想要的。 full-out 缩放显示两倍长,因为那是中点重复。虽然有一些异常需要解释(例如在 110 左右)。
这是图块提供商记录的“缩放”图表。因此每个 x-axis 点将代表一个单独的图块获取。
背景
之前似乎有人在 SO 上问过许多类似的问题(最著名的是 android google maps not loading the map when using GoogleMap.AnimateCamera() and How can I smoothly pan a GoogleMap in Android?),但是 none 在这些线程中发布的答案或评论让我对如何这样做。
我最初认为它会像调用 animateCamera(CameraUpdateFactory.newLatLng(), duration, callback)
一样简单,但就像上面第一个 link 的 OP 一样,在动画完成之前我得到的只是一个灰色或非常模糊的地图,哪怕我把它慢到几十秒!
我设法找到并实现了 this helper class,它可以很好地让图块沿途呈现,但即使延迟为 0,每个动画之间也有明显的滞后。
代码
好的,是时候写一些代码了。这是(稍微修改过的)助手 class:
package com.coopmeisterfresh.googlemaps.NativeModules;
import android.os.Handler;
import com.google.android.gms.maps.CameraUpdate;
import com.google.android.gms.maps.GoogleMap;
import java.util.ArrayList;
import java.util.List;
public class CameraUpdateAnimator implements GoogleMap.OnCameraIdleListener {
private final GoogleMap mMap;
private final GoogleMap.OnCameraIdleListener mOnCameraIdleListener;
private final List<Animation> cameraUpdates = new ArrayList<>();
public CameraUpdateAnimator(GoogleMap map, GoogleMap.
OnCameraIdleListener onCameraIdleListener) {
mMap = map;
mOnCameraIdleListener = onCameraIdleListener;
}
public void add(CameraUpdate cameraUpdate, boolean animate, long delay) {
if (cameraUpdate != null) {
cameraUpdates.add(new Animation(cameraUpdate, animate, delay));
}
}
public void clear() {
cameraUpdates.clear();
}
public void execute() {
mMap.setOnCameraIdleListener(this);
executeNext();
}
private void executeNext() {
if (cameraUpdates.isEmpty()) {
mOnCameraIdleListener.onCameraIdle();
} else {
final Animation animation = cameraUpdates.remove(0);
new Handler().postDelayed(() -> {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}, animation.mDelay);
}
}
@Override
public void onCameraIdle() {
executeNext();
}
private static class Animation {
private final CameraUpdate mCameraUpdate;
private final boolean mAnimate;
private final long mDelay;
public Animation(CameraUpdate cameraUpdate, boolean animate, long delay) {
mCameraUpdate = cameraUpdate;
mAnimate = animate;
mDelay = delay;
}
}
}
我的实现代码:
// This is actually a React Native Component class, but I doubt that should matter...?
public class NativeGoogleMap extends SimpleViewManager<MapView> implements
OnMapReadyCallback, OnRequestPermissionsResultCallback {
// ...Other unrelated methods removed for brevity
private void animateCameraToPosition(LatLng targetLatLng, float targetZoom) {
// googleMap is my GoogleMap instance variable; it
// gets properly initialised in another class method
CameraPosition currPosition = googleMap.getCameraPosition();
LatLng currLatLng = currPosition.target;
float currZoom = currPosition.zoom;
double latDelta = targetLatLng.latitude - currLatLng.latitude;
double lngDelta = targetLatLng.longitude - currLatLng.longitude;
double latInc = latDelta / 5;
double lngInc = lngDelta / 5;
float zoomInc = 0;
float minZoom = googleMap.getMinZoomLevel();
float maxZoom = googleMap.getMaxZoomLevel();
if (lngInc > 15 && currZoom > minZoom) {
zoomInc = (minZoom - currZoom) / 5;
}
CameraUpdateAnimator animator = new CameraUpdateAnimator(googleMap,
() -> googleMap.animateCamera(CameraUpdateFactory.zoomTo(
targetZoom), 5000, null));
for (double nextLat = currLatLng.latitude, nextLng = currLatLng.
longitude, nextZoom = currZoom; Math.abs(nextLng) < Math.abs(
targetLatLng.longitude);) {
nextLat += latInc;
nextLng += lngInc;
nextZoom += zoomInc;
animator.add(CameraUpdateFactory.newLatLngZoom(new
LatLng(nextLat, nextLng), (float)nextZoom), true);
}
animator.execute();
}
}
问题
有没有更好的方法来完成这个看似简单的任务?我在想,也许我需要将我的动画移动到工作线程或其他东西;会有帮助吗?
感谢阅读(我知道这是一个努力 :P)!
2021 年 9 月 30 日更新
我已经根据 Andy 在评论中的建议更新了上面的代码,虽然它可以工作(尽管有相同的滞后和渲染问题),但最终的算法需要更复杂一些,因为我想缩小到纵向三角洲的中途点,然后随着旅程的继续返回。
同时进行所有这些计算,以及平滑地渲染所有必要的图块同时,对于我的廉价手机 phone 来说似乎太多了正在测试。或者这是 API 本身的限制?在任何情况下,我怎样才能让所有这些工作顺利进行,在排队的动画之间没有任何延迟?
这是我使用你的实用框架播放器的尝试。
一些注意事项:
- 缩放值根据总步数(此处设置为 500)并给定起始值和停止值进行插值。
- Google 地图实用程序用于根据分数距离计算下一个经纬度:
SphericalUtil.interpolate
。 - 分数距离不应是线性函数,以减少引入新图块。换句话说,在更高的变焦(更近)时,相机移动的距离更短,并且相机移动量在缩小时呈指数增加(center-to-center)。这需要更多解释...
- 如您所见,遍历分为两部分 - 反转距离移动的指数函数。
- 最远的“最大”缩放(坏名)可以是总距离的函数 - 计算为在中点包含整个路径。现在,对于这种情况,它被硬编码为 4。
- 请注意地图
animate
函数 不能 被使用,因为它在每个步骤 上引入了自己的弹跳球效果 这是不受欢迎的。因此,给定一定数量的步骤,可以使用move
函数。 - 此方法试图最大限度地减少每一步的图块加载,但最终 TileLoader 是无法(轻松)监控的查看限制因素。
animateCameraToPosition
// flag to control the animate callback (at completion).
boolean done = false;
private void animateCameraToPosition(LatLng targetLatLng, float targetZoom) {
CameraPosition currPosition = gMap.getCameraPosition();
LatLng currLatLng = currPosition.target;
//meters_per_pixel = 156543.03392 * Math.cos(latLng.lat() * Math.PI / 180) / Math.pow(2, zoom)
int maxSteps = 500;
// number of steps between start and midpoint and midpoint and end
int stepsMid = maxSteps / 2;
// current zoom
float initz = currPosition.zoom;
//TODO maximum zoom (can be computed from overall distance) such that entire path
// is visible at midpoint.
float maxz = 4.0f;
float finalz = targetZoom;
CameraUpdateAnimator animator = new CameraUpdateAnimator(gMap, () -> {
if (!done) {
gMap.animateCamera(CameraUpdateFactory.
zoomTo(targetZoom), 5000, null);
}
done = true;
});
// loop from start to midpoint
for (int i = 0; i < stepsMid; i++) {
// compute interpolated zoom (current --> max) (linear)
float z = initz - ((initz - maxz) / stepsMid) * i;
// Compute fractional distance using an exponential function such that for the first
// half the fraction delta advances slowly and accelerates toward midpoint.
double ff = (i * (Math.pow(2,maxz) / Math.pow(2,z))) / maxSteps;
LatLng nextLatLng =
SphericalUtil.interpolate(currLatLng, targetLatLng, ff);
animator.add(CameraUpdateFactory.newLatLngZoom(
nextLatLng, z), false, 0);
}
// loop from midpoint to final
for (int i = 0; i < stepsMid; i++) {
// compute interpolated zoom (current --> max) (linear)
float z = maxz + ((finalz - maxz) / stepsMid) * i;
double ff = (maxSteps - ((i+stepsMid) * ( (Math.pow(2,maxz) / Math.pow(2,z)) ))) / (double)maxSteps;
LatLng nextLatLng =
SphericalUtil.interpolate(currLatLng, targetLatLng, ff);
animator.add(CameraUpdateFactory.newLatLngZoom(
nextLatLng, z), false, 0);
}
animator.add(CameraUpdateFactory.newLatLngZoom(
targetLatLng, targetZoom), true, 0);
//
animator.execute();
}
测试代码
我用这两个点(和代码)从自由女神像到西海岸的一个点进行了测试:
gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(40.68924, -74.04454), 13.0f));
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
animateCameraToPosition(new LatLng(33.899832, -118.020450), 13.0f);
}
}, 5000);
CameraUpdateAnimator 模组
我稍微修改了相机更新动画:
public void execute() {
mMap.setOnCameraIdleListener(this);
executeNext();
}
private void executeNext() {
if (cameraUpdates.isEmpty()) {
mMap.setOnCameraIdleListener(mOnCameraIdleListener);
mOnCameraIdleListener.onCameraIdle();
} else {
final Animation animation = cameraUpdates.remove(0);
// This optimization is likely unnecessary since I think the
// postDelayed does the same on a delay of 0 - execute immediately.
if (animation.mDelay > 0) {
new Handler().postDelayed(() -> {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}, animation.mDelay);
} else {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}
}
}
采样前
正在使用
// assume initial (40.68924, -74.04454) z=13.0f
gMap.animateCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(33.899832,-118.020450), 13.0f), 30000, null);
采样后
这些是从模拟器录制的。我还侧载到我的 phone (Samsumg SM-G960U) 上,结果相似(使用 1000 步 0 延迟)。
所以我认为这并不完全符合你的要求:有一些“模糊的瓷砖”,因为它们是从西方引进的。
自由女神像 - 到 - 圣地亚哥附近某处
诊断
在某些方面了解地图对图块的处理方式很有用。可以通过安装一个简单的 UrlTileProvider
并记录请求来提供洞察力。此实现获取 google 个图块,尽管它们的分辨率通常较低。
为此,需要满足以下条件:
// Turn off this base map and install diagnostic tile provider
gMap.setMapType(GoogleMap.MAP_TYPE_NONE);
gMap.addTileOverlay(new TileOverlayOptions().tileProvider(new MyTileProvider(256,256)).fadeIn(true));
并定义诊断文件提供程序
public class MyTileProvider extends UrlTileProvider {
public MyTileProvider(int i, int i1) {
super(i, i1);
}
@Override
public URL getTileUrl(int x, int y, int zoom) {
Log.i("tiles","x="+x+" y="+y+" zoom="+zoom);
try {
return new URL("http://mt1.google.com/vt/lyrs=m&x="+x+"&y="+y+"&z="+zoom);
} catch (MalformedURLException e) {
e.printStackTrace();
return null;
}
}
}
您会立即注意到切片图层始终以整数单位 (int
) 定义。缩放中提供的分数缩放(例如 LatLngZoom
严格使用 in-memory 图像 - 很高兴知道。'
这里有一个完整的示例:
// initial zoom
x=2411 y=3080 zoom=13
x=2410 y=3080 zoom=13
x=2411 y=3081 zoom=13
x=2410 y=3081 zoom=13
x=2411 y=3079 zoom=13
x=2410 y=3079 zoom=13
最大:
x=9 y=12 zoom=5
x=8 y=12 zoom=5
x=9 y=11 zoom=5
x=8 y=11 zoom=5
x=8 y=13 zoom=5
x=9 y=13 zoom=5
x=7 y=12 zoom=5
x=7 y=11 zoom=5
x=7 y=13 zoom=5
x=8 y=10 zoom=5
x=9 y=10 zoom=5
x=7 y=10 zoom=5
这是每次调用平铺程序 (x-axis) 时的缩放 (y-axis) 图表。每个缩放层 大致 相同的数量,这是我想要的。 full-out 缩放显示两倍长,因为那是中点重复。虽然有一些异常需要解释(例如在 110 左右)。
这是图块提供商记录的“缩放”图表。因此每个 x-axis 点将代表一个单独的图块获取。