目录
写在开头
正文
一、界面布局
二、功能实现
1.显示在线地图和定位
2.基站信息的获取和显示
3.地理信息的获取和解析
4.显示栅格离线地图(.tpk)
5.显示矢量离线地图(.vtpk)
三、主要问题及解决方案
写在开头
这个APP的开发是我们这学期的一门课的期末大作业,要求主要有三点:显示栅格离线地图切片(.tpk),显示矢量离线地图切片(.vtpk),基于基站实现手机定位。
作为第一次接触安卓开发的小白,从Android Studio的安装到最终实现功能,这中间的每一步我都走得很艰难。做APP的这一周我遇到大大小小很多问题,也重写了很多次代码。无数次想要放弃,但每次都告诉自己再坚持一下看能不能解决,最终成功做出了这个APP,可喜可贺。其实这个APP并不完善,但是第一次零基础做到这样,我已经很满意了。这里主要记录一下APP的实现过程以及自己遇到的各种问题和解决办法,希望对有需要的小伙伴有帮助。
正文
以实现功能为主,本文主要展示核心代码,关于代码细节和一些原理并未详细描述。
一、界面布局
根据功能设计,首先我的界面需要一个ArcGIS中的MapView,用来显示地图;还需要三个按钮,分别用来切换显示本地栅格、本地矢量、在线地图;另外需要两个TextView,分别用来显示基站信息和地理信息。
我的界面大概就长下面这样:
整个界面的结构是MapView在最外层,如下:
上述界面是直接用代码实现的。如果不用代码而是要手动调整的话,我也不知道怎么实现。下面说说实现具体步骤:
1.在如下图的位置中找到“res”文件夹下的“layout”文件夹里的一个叫做“activity_main.xml”的文件:
2.打开上述xml文件,第一次应该首先看的是设计界面,在右上角点击Code切换到代码界面:
3.在“RelativeLayout”标签里写上我们整个界面的设计代码,如下:
<com.esri.arcgisruntime.mapping.view.MapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="40dp"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingRight="20dp">
<Button
android:id="@+id/button1"
android:layout_width="20dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="栅格" />
<Button
android:id="@+id/button2"
android:layout_width="20dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="矢量" />
<Button
android:id="@+id/button3"
android:layout_width="20dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="在线" />
</LinearLayout>
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:text="基站信息" />
<TextView
android:id="@+id/lacationText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:text="地理位置" />
</LinearLayout>
</com.esri.arcgisruntime.mapping.view.MapView>
4.返回到设计界面,可以查看界面是否是上述的布局。
5.在MainActivity.java的“onCreate”里添加对上述三个按钮的点击事件,主要实现按钮上文本的变化,代码如下:
btngrid.setOnClickListener(new View.OnClickListener() {//栅格按钮
@Override
public void onClick(View view) {
btngrid.setText("栅格(now)");
btnvector.setText("矢量");
btnonline.setText("在线");
}
});
btnvector.setOnClickListener(new View.OnClickListener() {//矢量按钮
@Override
public void onClick(View view) {
btnvector.setText("矢量(now)");
btngrid.setText("栅格");
btnonline.setText("在线");
}
});
btnonline.setOnClickListener(new View.OnClickListener() {//在线按钮
@Override
public void onClick(View view) {
btnonline.setText("在线(now)");
btnvector.setText("矢量");
btngrid.setText("栅格");
}
});
二、功能实现
1.显示在线地图和定位
在线地图和定位的显示直接参考ArcGIS的安卓开发教程。
在线地图显示:https://developers.arcgis.com/labs/android/create-a-starter-app/
定位显示:https://developers.arcgis.com/labs/android/display-and-track-your-location/
下面贴一下自己程序中的代码。
首先是在线地图显示函数。其中Basemap的Type可以改的,我这里是栅格地图,你也可以设置成矢量图、街景图等等。代码如下:
//显示在线地图
private void setupMap() {
if (mMapView != null) {
Basemap.Type basemapType = Basemap.Type.IMAGERY_WITH_LABELS;
double latitude = 34.09042;
double longitude = -118.71511;
int levelOfDetail = 6;
map = new ArcGISMap(basemapType, latitude, longitude, levelOfDetail);
mMapView.setMap(map);
}
}
重写三个函数,以正确显示在线地图,直接写在“MainActivity”类中。代码如下:
@Override
protected void onPause() {
if (mMapView != null) {
mMapView.pause();
}
super.onPause();
}
@Override
protected void onResume() {
super.onResume();
if (mMapView != null) {
mMapView.resume();
}
}
@Override
protected void onDestroy() {
if (mMapView != null) {
mMapView.dispose();
}
super.onDestroy();
}
定位显示(功能实现函数+导航权限开启许可函数):
//位置导航
private void setupLocationDisplay() {
mLocationDisplay = mMapView.getLocationDisplay();
mLocationDisplay.addDataSourceStatusChangedListener(dataSourceStatusChangedEvent -> {
// If LocationDisplay started OK or no error is reported, then continue.
if (dataSourceStatusChangedEvent.isStarted() || dataSourceStatusChangedEvent.getError() == null) {
return;
}
int requestPermissionsCode = 2;
String[] requestPermissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION};
// If an error is found, handle the failure to start.
// Check permissions to see if failure may be due to lack of permissions.
if (!(ContextCompat.checkSelfPermission(MainActivity.this, requestPermissions[0]) == PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(MainActivity.this, requestPermissions[1]) == PackageManager.PERMISSION_GRANTED)) {
// If permissions are not already granted, request permission from the user.
ActivityCompat.requestPermissions(MainActivity.this, requestPermissions, requestPermissionsCode);
} else {
// Report other unknown failure types to the user - for example, location services may not
// be enabled on the device.
String message = String.format("Error in DataSourceStatusChangedListener: %s", dataSourceStatusChangedEvent
.getSource().getLocationDataSource().getError().getMessage());
Toast.makeText(MainActivity.this, message, Toast.LENGTH_LONG).show();
}
});
mLocationDisplay.setAutoPanMode(LocationDisplay.AutoPanMode.COMPASS_NAVIGATION);
mLocationDisplay.startAsync();
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
// If request is cancelled, the result arrays are empty.
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Location permission was granted. This would have been triggered in response to failing to start the
// LocationDisplay, so try starting this again.
mLocationDisplay.startAsync();
} else {
// If permission was denied, show toast to inform user what was chosen. If LocationDisplay is started again,
// request permission UX will be shown again, option should be shown to allow never showing the UX again.
// Alternative would be to disable functionality so request is not shown again.
Toast.makeText(MainActivity.this, getResources().getString(R.string.location_permission_denied), Toast.LENGTH_SHORT).show();
}
}
在线地图显示”和“定位显示”在MainActivity.java的“onCreate”里调用,保证界面打开就能展示;另外“在线地图显示”在“在线按钮”中再调用一次,用于切换地图。OnCreate里调用代码:
mMapView = findViewById(R.id.mapView);
setupMap();
setupLocationDisplay();
最后,需要在app > manifests > AndroidManifest.xml里开启权限。
在线地图显示权限(网络访问+OpenGL渲染):
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
导航显示权限(GPS位置):
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
注意:导航权限开启许可函数(onRequestPermissionsResult)和三个Override的函数不需要调用,直接执行上述操作就行了。
效果图(在线地图+导航)展示如下。这是最终成果图,所以有基站信息和位置信息,我先马赛克掉了。
2.基站信息的获取和显示
先来了解一下什么是基站信息。基站信息主要指以下四个参数:
mcc:国家代码(中国为460)
mnc:网络类型(移动为0,联通为1,电信为sid)
lac:位置区域码
cid: 基站编号
上述基站信息是存储在数据库中的,我们只需要调用函数,它便能根据手机地址在数据库中查询所需信息。网上有很多现成的相关代码,我的代码参考了Android实现基站定位一文。
首先用一个结构体来存储基站信息,结构体构建如下:
/** 基站信息结构体 */
public class SCell {
public int MCC;
public int MNC;
public int LAC;
public int CID;
}
下面是获取基站信息的代码:
//获取基站信息
private String getBaseStationInformation(){
StringBuffer sbtv = new StringBuffer();
cell = new SCell();//基站信息
List<CellInfo> all_cell_info;//所有基站信息
sbtv.append("基站信息"+"\r\n");
if (mTelephonyManager == null) {
mTelephonyManager = (TelephonyManager) getApplicationContext().getSystemService(Context.TELEPHONY_SERVICE);
}
// 返回值MCC + MNC
String operator = mTelephonyManager.getNetworkOperator();
if (operator != null && operator.length() > 3) {
cell.MCC = Integer.parseInt(operator.substring(0, 3));
cell.MNC = Integer.parseInt(operator.substring(3));
sbtv.append("mcc:" + cell.MCC + ";mnc:" + cell.MNC + ";\r\n");
}
// 获取邻区基站信息
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
&& ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
all_cell_info = mTelephonyManager.getAllCellInfo();
sbtv.append("基站数量:" + all_cell_info.size() + "\r\n");
CellInfo cellInfo = all_cell_info.get(0);
GsmCellLocation location = (GsmCellLocation) mTelephonyManager.getCellLocation();
if (location == null)
Toast.makeText(getApplicationContext(), "获取基站信息失败", Toast.LENGTH_SHORT).show();
cell.CID = location.getCid();
cell.LAC = location.getLac();
sbtv.append("最近基站:"+"cid:" + cell.CID + ";lac:" + cell.LAC + ";\r\n");
} else {
Toast.makeText(getApplicationContext(), "请检查定位是否开启", Toast.LENGTH_SHORT).show();
}
return sbtv.toString();
}
上述函数将返回一个字符串文本,包含四个基站信息,在OnCreate方法里调用该函数,将结果显示在TextView里。调用代码如下:
text1 = findViewById(R.id.textView);
text1.setText(getBaseStationInformation());
效果图展示:
3.地理信息的获取和解析
这一步对我来说应该最难实现、耗时最多的一个环节了,因为里面用到一些HTTP的接口,还用到了子线程。另外,这一步遇到一个具大的问题就是:获取地理信息和在线地图显示两者居然不能同时实现!具体原因及解决思路在第三个部分-“问题及解决方案”中再详细探讨。这里主要说说如何实现地理信息的获取。
获取到的地理信息包括:经纬度和地址。
获取地理信息的思路:通过GET请求,利用四个基站信息在接口地址查询,返回数据的格式为CSV/JSON/XML。具体的接口说明和参数说明可以访问这个网址:接口说明文档
这里给一个访问接口的例子:http://api.cellocation.com:81/cell/?mcc=460&mnc=1&lac=4301&ci=20986&output=csv。返回的数据如下:
0,40.008899,116.483642,903,"北京市朝阳区望京开发街道屏翠东路;利泽东街与屏翠东路路口东189米"
这是一串字符串,我们最终只需要显示经纬度和地理位置,其余的信息就不用管了。
知道了思路,下面说一下如何用代码来获取信息。代码主要包括两个函数:函数getStringInfo用来访问接口,返回上述的字符串;函数getLocaionInfo用来解析上述字符串,从中剔出经纬度和地址存储在结构体中。
地理信息存储结构体构建如下:
/** 经纬度和地理位置信息结构体 */
public class SLocationInfo {
public String latitude;
public String longitude;
public String address;
}
函数getStringInfo:
/** get网站经纬度和地理位置信息 */
private String getStringInfo(String domain) throws Exception {
String resultString = "";
/** 采用Android默认的HttpClient */
HttpClient client = new DefaultHttpClient();
/** 采用GET方法 */
HttpGet get = new HttpGet(domain);
try {
/** 发起GET请求并获得返回数据 */
HttpResponse response = client.execute(get);
HttpEntity entity = response.getEntity();
BufferedReader buffReader = new BufferedReader(new InputStreamReader(entity.getContent()));
StringBuffer strBuff = new StringBuffer();
String result = null;
while ((result = buffReader.readLine()) != null) {
strBuff.append(result);
}
resultString = strBuff.toString();
} catch (Exception e) {
Log.e(e.getMessage(), e.toString());
throw new Exception("获取经纬度和地理位置出现错误:"+e.getMessage());
} finally{
get.abort();
client = null;
}
return resultString;
}
函数getLocaionInfo(在这里面调用上面的函数getStringInfo):
//解析获取的经纬度和地理位置信息
private String getLocaionInfo(String url) throws Exception {
locationinfo = new SLocationInfo();
StringBuffer sblocationinfo = new StringBuffer();
String resultString = "";
resultString = getStringInfo(url);
sblocationinfo.append("位置信息"+"\r\n");
/** 解析基站位置 */
try{
String errcode = "";
String[] arr = resultString.split(",");
errcode = arr[0];
if (errcode.equals("0")){
locationinfo.latitude = arr[1];
locationinfo.longitude = arr[2];
locationinfo.address = arr[4];
sblocationinfo.append("经度:"+locationinfo.longitude+";纬度:"+locationinfo.latitude+";地理位置:"+locationinfo.address+"\r\n");
}
} catch (Exception e) {
Log.e(e.getMessage(), e.toString());
throw new Exception("获解析地理位置出现错误:"+e.getMessage());
}
return sblocationinfo.toString();
}
函数getLocaionInfo需要单独开设一个子线程来调用,因为访问网站是很耗时间的,子线程可以大大减少主线程的负担。但是在这个处理网站访问的子线程里不能处理UI,也就是在TextView里显示地理信息这一操作不能在子线程中实现,因此还需要使用一个UI子线程。前面说得可能有点绕,简单来说就是:网站访问子线程用于处理网站访问操作,当成功获得返回数据后,给UI子线程发送一个成功信号,然后UI子线程用于刷新界面,显示获取的数据。下面直接上代码:
定义用于通知UI子线程的成功信号和失败信号:
private static final int MSG_SUCCESS = 0;// 获取成功的标识
private static final int MSG_FAILURE = 1;// 获取失败的标识
网站访问子线程runnable:
private Runnable runnable = new Runnable() {
// 重写run()方法,此方法在新的线程中运行,用于访问网站获取经纬度和地理位置
@Override
public void run() {
try {
locationText = getLocaionInfo(url);
} catch (Exception e) {
e.printStackTrace();
handler.obtainMessage(MSG_FAILURE).sendToTarget();//给UI子线程发送失败信息
} finally {
handler.obtainMessage(MSG_SUCCESS).sendToTarget();//给UI子线程发送成功信息
}
}
};
UI子线程handler:
private Handler handler = new Handler() {
//UI子线程,用于显示经纬度和地理位置信息
@Override
public void handleMessage(Message msg) {
try{
switch (msg.what){
case MSG_SUCCESS:
text2.setText(locationText);
Log.e("Success", "解析基站位置成功");
break;
case MSG_FAILURE:
Log.e("Failure", "解析基站位置失败");
break;
}
}catch (Exception e) {
e.printStackTrace();
}
}
};
在OnCreate中运行子线程:
url = "http://api.cellocation.com:81/cell/?mcc=" + cell.MCC + "&mnc=" + cell.MNC + "&lac=" + cell.LAC + "&ci=" + cell.CID + "&output=csv";
text2 = findViewById(R.id.lacationText);
try {
mThread = new Thread(runnable);
mThread.start();
} catch (Exception e) {
Toast.makeText(MainActivity.this, e.getMessage(), Toast.LENGTH_SHORT).show();
}
效果图展示:
4.显示栅格离线地图(.tpk)
tpk数据来源:由影像图通过ArcGIS制作而成,具体教程网上很多,大家可以自行搜索。
本地栅格图的显示有两个注意点:一是数据存储位置,二是数据范围。首先是数据存储位置,我使用的绝对位置,所以只有真机测试才能打开数据,如果用模拟器的话就要用其他方式读取数据存储位置了。数据范围一定要包含自己测试所用手机的GPS位置,如果没有,那么就无法显示地图。具体代码比较简单,如下:
定义绝对路径和栅格地图:
private String dirpath = Environment.getExternalStorageDirectory().getAbsolutePath();//本地数据路径
private ArcGISTiledLayer localTiledLayer;//本地栅格地图
本地栅格显示函数:
//打开本地栅格
private void openMyTPK(){
Toast.makeText(getApplicationContext(), dirpath + "/ArcGIS" + "/santai.tpk", Toast.LENGTH_SHORT).show();
String fileLayer = dirpath+"/ArcGIS"+"/santai.tpk";
localTiledLayer = new ArcGISTiledLayer(fileLayer);
baseMap = new Basemap(localTiledLayer);
map = new ArcGISMap(baseMap);
mMapView.setMap(map);
}
在“栅格”按钮里调用上述函数,效果图展示如下:
5.显示矢量离线地图(.vtpk)
vtpk数据来源:网上下载。
矢量离线地图显示的代码和前面的栅格地图几乎一样,唯一不一样的地方就是定义图层的格式不同。栅格是ArcGISTiledLayer,矢量是ArcGISVectorTiledLayer。下面是代码:
//打开本地矢量地图
private void openMyVTPK() {
try {
Toast.makeText(getApplicationContext(), dirpath+"/ArcGIS"+"/china.vtpk", Toast.LENGTH_SHORT).show();
String fileLayer = dirpath+"/ArcGIS"+"/china.vtpk";
vTiledLayer = new ArcGISVectorTiledLayer(fileLayer);
baseMap = new Basemap(vTiledLayer);
map = new ArcGISMap(baseMap);
mMapView.setMap(map);
} catch (Exception e)
{
String eResult = e.getMessage();
Toast.makeText(MainActivity.this, eResult, Toast.LENGTH_SHORT).show();
}
}
效果图展示:
三、主要问题及解决方案
1.用安卓模拟器运行app时出现找不到device的错误
这是因为电脑BIOS中的虚拟技术没有打开,重启电脑按F2(因电脑而异,我的是联想)进入BIOS打开就行了。教程网上搜索一下就有,需要注意的是进入BIOS界面的时间是电脑开机后刚刚亮的那个瞬间,早了晚了都进不去,我重启了四次才进去。。。
2.本地地图显示不出来
按照我的代码,要想正确显示本地地图一定要在真机上测试,因为是绝对路径。路径是:本记SD卡->ArcGIS(自己建的文件夹)->xxx.tpk/xxx.vtpk。还要注意两点:1.保证离线地图上有自己手机目前的位置;2.开启读写权限,在AndroidManifest.xml的manifest标签写入代码如下:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
3.Get请求中的代码找不到对应的方法,例如:DefaultHttpClient
这和Android的SDK版本有关,我的编译版本是28,最小版本是19。在我使用的版本中安卓弃用了上述的一些方法,网上有说更换版本的,但是很麻烦不说还会造成其他代码的冲突。其实在build.gradle(app)中加入所需的包就行了,在android块中写入:
useLibrary'org.apache.http.legacy'
然后点击右上角的“Sync Now”,这样就可以正常使用Get方法了。
4.点击按钮,app就闪退
原因不太清楚,但解决办法是:在AndroidManifest.xml的application标签下添加如下代码:
<uses-library android:name="org.apache.http.legacy" android:required="false"/>
5.Get获取地理信息与在线地图显示不能同时实现
原因:获取地理信息的网址是Http协议,即明文协议,而在线地图是Https协议,Android Studio默认Https协议。不做任何设置的话,能显示在线地图,但是不能获取到地理信息。
解决方法:添加一个xml文件,作为安全协议设置,保证Http和Https都能访问。
具体步骤:在res文件夹下新建xml文件夹,然后在xml文件夹下新建一个名为“network_security_config.xml”的文件,在里面写入以下代码:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
6.运行app时出现闪退现象
这个可能的原因有很多。我自己是在运行app前没有提前打开GPS,解决办法就是要记得提前打开GPS。