Android的口罩地圖App–使用Webkit網頁
李政輝 C.H. Lee
- 精誠資訊/恆逸教育訓練中心-資深講師
- 技術分類:Mobile行動應用開發
最近由於COVID-19疫情,大家出門都口罩不離身,所以寫個口罩地圖程式顯示現在各位置口罩剩餘數量,也是最近老師們最常使用的程式範例。在這個文章中,我將製作一個Android APP來顯示口罩地圖。
技術主題:
- 建立一個網頁使用GOOGLE Maps JavaScript API
- 透過XMLHttpRequest執行AJAX非同步作業存取口罩地圖API
- 將每一個口罩購買點及口罩資訊轉成GOOGLE Maps Marker物件呈現在地圖上
- 透過Android WebKit使用口罩地圖網頁
- 透過Google Play Services取出定位資訊並傳給WebKit網頁顯示口罩地圖
1.建立一個網頁使用GOOGLE Maps JavaScript API
- 請先向Google Maps JavaScript API (https://developers.google.com/maps/documentation/javascript/overview) 申請一組API金鑰,並在 HTML文件中添加如下所示的script加載 Maps JavaScript API。
<script async
src="https://maps.googleapis.com/maps/api/js?key=您申請的API金鑰&callback=initMap">
</script>
- 網頁MaskMap.html完整程式碼如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Google Maps</title>
<!--Google Map Javascripy API-->
<script async src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap"></script>
<script>
var map;
function initialize()
{
var lat = 25.0525;//緯度
var lon = 121.544;//經度
var latlon = new google.maps.LatLng(lat,lon);
var mapOptions =
{
zoom: 15,//比例尺
mapTypeId: google.maps.MapTypeId.ROADMAP,//街道地圖
center: latlon//地圖中心點
};
//建立Map物件顯示地圖在網頁指定位置
map = new google.maps.Map(document.getElementById('map'),mapOptions);
}
</script>
</head>
- 執行結果如下:
2.透過XMLHttpRequest執行AJAX非同步作業存取口罩地圖API
- 口罩地圖API API網址: https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json?fbclid=IwAR0oowBRjj1goAMqtnugBiXMTMY8OCl14TGmgt3YDJi9w5BXs4VsfZQ9mDI
- 取得的JSON格式如下
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"id": "5937251123",
"name": "上明藥局",
"phone": "(04)8970600",
"address": "彰化縣竹塘鄉竹塘村竹塘街66號",
"mask_adult": 6710,
"mask_child": 1900,
"updated": "2021\/06\/07 09:47:24",
"available": "星期一上午看診、星期二上午看診、星期三上午看診、星期四上午看診、星期五上午休診、星期六上午看診、星期日上午看診、星期一下午看診、星期二下午看診、星期三下午看診、星期四下午看診、星期五下午休診、星期六下午看診、星期日下午休診、星期一晚上休診、星期二晚上休診、星期三晚上休診、星期四晚上休診、星期五晚上休診、星期六晚上休診、星期日晚上休診",
"note": "-",
"custom_note": "",
"website": "",
"county": "彰化縣",
"town": "竹塘鄉",
"cunli": "竹塘村",
"service_periods": "NNNNYNNNNNNYNYYYYYYYY"
},
"geometry": {
"type": "Point",
"coordinates": [
120.42594,
23.860806
]
}
},
…….
- 透過XMLHttpRequest執行AJAX非同步作業
//建立XMLHttpRequest物件
var request = new XMLHttpRequest();
function getJSONData() {
var json_url = 'https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json?fbclid=IwAR0oowBRjj1goAMqtnugBiXMTMY8OCl14TGmgt3YDJi9w5BXs4VsfZQ9mDI';
//非同步ASYNC作業觸發readystatechange事件
request.addEventListener("readystatechange", dotheupdate, false);
request.open("GET", json_url, true);//true代表非同步ASYNC作業
request.send();
}
function dotheupdate() {
if (request.readyState == 4) {//4代表ASYNC作業完成
//透過JSON.parse反序列化函數將responseText的JSON字串還原成JS物件
var obj = JSON.parse(request.responseText);
console.log(obj.features);
var data = obj.features;
for (var i = 0; i < data.length; i++) {
console.log('lon='+data[i].geometry.coordinates[0])
console.log('lat='+data[i].geometry.coordinates[1])
}
}
}
- 網頁MaskMap.html完整程式碼如下
Google Maps
- 執行結果如下:
3. 將每一個口罩購買點及口罩資訊轉成GOOGLE Maps Marker物件呈現在地圖上
- 替每個藥局產生GOOGLE Maps Marker物件及InfoWindow物件
function getMarker(lon, lat, title, message) {
//建立Google Map Marker物件(預設為紅色水滴)
var marker = new google.maps.Marker(
{ position: { lat: lat, lng: lon },//經緯度
title: title });//title顯示藥局名稱
//建立Google Map InfoWindow物件
var infowindow = new google.maps.InfoWindow({ content: message });
//在Marker物件點擊觸發InfoWindow視窗
marker.addListener("click", function() { infowindow.open(map, marker)});
//在Map地圖產生Marker物件
marker.setMap(map);
}
}
- 網頁MaskMap.html完整程式碼
Google Maps
- 執行結果如下:
4.透過Android WebKit使用口罩地圖網頁
- 在Android 新增一個Empty Activity專案
- Android專案結構
- 在專案加入Assets資料夾(按右鍵🠖New🠖folder🠖Assets Folder)
- 將Maskmap.html複製貼上至Assets資料夾(按右鍵🠖Paste)
- 修改Maskmap.html 將經緯度改由原生Layout EditText提供
function initialize()
{
//var lat = 25.0525;//緯度
//var lon = 121.544;//經度
var lat = AndroidFunction.GetLat();
var lon = AndroidFunction.GetLon();
…..
- 在res🠖layout資料夾新增一個Layout Resouce File: map.xml
- 修改activity_main.xml
- 將cancel.jpg複製貼上至res🠖drawable資料夾(按右鍵🠖Paste)
- 修改ActivityMain.java
function initialize()
{
//var lat = 25.0525;//緯度
//var lon = 121.544;//經度
var lat = AndroidFunction.GetLat();
var lon = AndroidFunction.GetLon();
…..
- 在res🠖layout資料夾新增一個Layout Resouce File: map.xml
- 修改activity_main.xml
- 將cancel.jpg複製貼上至res🠖drawable資料夾(按右鍵🠖Paste)
- 修改ActivityMain.java
package com.example.maskmap;
import androidx.appcompat.app.AppCompatActivity;
import android.app.AlertDialog;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
public class MainActivity extends AppCompatActivity {
EditText latedittext;
EditText lonedittext;
EditText addressedittext;
private String Lat;
private String Lon;
private AlertDialog alerdialog;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button MapButton = (Button) findViewById(R.id.buttonmap);
MapButton.setOnClickListener(Click);
}
@JavascriptInterface
public String GetLat()
{
return Lat;
}
@JavascriptInterface
public String GetLon()
{
return Lon;
}
View.OnClickListener Click= new View.OnClickListener ()
{
@Override
public void onClick(View view)
{
switch(view.getId())
{
case R.id.buttonmap:
latedittext = (EditText) findViewById(R.id.editTextLat);
lonedittext = (EditText) findViewById(R.id.editTextLon);
Lat = latedittext.getText().toString();
Lon = lonedittext.getText().toString();
if (Lat != null && Lon != null)
{
AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
LayoutInflater inflater = LayoutInflater.from(MainActivity.this);
View mapview = inflater.inflate(R.layout.map,null);
builder.setView(mapview);
ImageView imageview = (ImageView)mapview.findViewById(R.id.imageviemap);
imageview.setOnClickListener(Click);
String mapurl = "file:///android_asset/MaskMap.html";
WebView webview = (WebView) mapview.findViewById(R.id.webviewmap);
webview.getSettings().setJavaScriptEnabled(true);
webview.addJavascriptInterface(MainActivity.this , "AndroidFunction");
webview.loadUrl(mapurl);
alerdialog = builder.show();
}
break;
case R.id.imageviemap:
alerdialog.cancel();
break;
}
}
};
}
- 修改manifests🠖AndroidManifest.xml 加入Internet使用權限
- 執行結果如下:
5.透過Google Play Services取出定位資訊並傳給WebKit網頁顯示口罩地圖
- 安裝Google Play Services取得定位(在Gradle Scriptsbuild.gradle(Module.MaskMap.app)檔案加入以下內容)
dependencies {
…
implementation 'com.google.android.gms:play-services:12.0.1'
…
}
- 按一下 工具列 的 Sync Project with Gradle Files下載元件
- 將以下權限添加到AndroidManifest.xml
- 在Gradle Scripts🠖gradle.properties(ProjectProperties)檔案加入以下內容以避免Duplicate class xxx found in modules錯誤
android.enableJetifier=true
- 修改ActivityMain.java 加入定位取得地址
package com.example.maskmap;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.IntentSender;
import android.content.pm.PackageManager;
import android.location.Address;
import android.location.Geocoder;
import android.location.Location;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.Toast;
import com.google.android.gms.common.api.ResolvableApiException;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationAvailability;
import com.google.android.gms.location.LocationCallback;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationResult;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.location.LocationSettingsRequest;
import com.google.android.gms.location.LocationSettingsResponse;
import com.google.android.gms.location.SettingsClient;
import com.google.android.gms.tasks.OnFailureListener;
import com.google.android.gms.tasks.OnSuccessListener;
import com.google.android.gms.tasks.Task;
import java.io.IOException;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "LocationActivity";
FusedLocationProviderClient fusedClient;
private LocationRequest mRequest;
private LocationCallback mCallback;
EditText latedittext;
EditText lonedittext;
EditText addressedittext;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button MapButton = (Button) findViewById(R.id.buttonmap);
MapButton.setOnClickListener(Click);
mCallback = new LocationCallback() {
//onLocationResult callback用於取得 "streaming"位置更新的地方.
//我們可以用來確定是否檢查諸如準確性之類的事情
//這個最新的更新應該取代我們之前的estimate估計.
@Override
public void onLocationResult(LocationResult locationResult) {
if (locationResult == null) {
Log.d(TAG, "locationResult null");
return;
}
for (Location loc : locationResult.getLocations()) {
latedittext = (EditText) findViewById(R.id.editTextLat);
lonedittext = (EditText) findViewById(R.id.editTextLon);
latedittext.setText(String.format("%g", loc.getLatitude()));
lonedittext.setText(String.format("%g", loc.getLongitude()));
}
}
@Override
public void onLocationAvailability(LocationAvailability locationAvailability) {
Log.d(TAG, "locationAvailability is " + locationAvailability.isLocationAvailable());
super.onLocationAvailability(locationAvailability);
}
};
//定位權限
if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
//請求權限permission.
//但是檢查我們是否需要先顯示一個請求權限的UI
if (ActivityCompat.shouldShowRequestPermissionRationale(this, android.Manifest.permission.ACCESS_COARSE_LOCATION)) {
showRationale();
} else {
ActivityCompat.requestPermissions(this, new String[]{android.Manifest.permission.ACCESS_COARSE_LOCATION, android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_NETWORK_STATE}, 2);
}
} else {
//我們已經有權限了,開始定位
locationWizardry();
}
}
@SuppressLint("MissingPermission")
private void locationWizardry() {
fusedClient = LocationServices.getFusedLocationProviderClient(this);
//最初,獲取最後一個已知位置。我們可以稍後不斷重新取得新的定位
fusedClient.getLastLocation().addOnSuccessListener(new OnSuccessListener() {
@Override
public void onSuccess(Location location) {
if (location != null) {
latedittext = (EditText) findViewById(R.id.editTextLat);
lonedittext = (EditText) findViewById(R.id.editTextLon);
latedittext.setText(String.format("%g", location.getLatitude()));
lonedittext.setText(String.format("%g", location.getLongitude()));
//使用Geocoder物件透過定位的經緯度反查地址
addressedittext = (EditText) findViewById(R.id.editTextAddress);
Geocoder geocoder = new Geocoder(MainActivity.this);
List addresses = null;
try {
addresses = geocoder.getFromLocation(location.getLatitude(), location.getLongitude(), 1); //放入座標
} catch (IOException e) {
e.printStackTrace();
}
if (addresses != null && addresses.size() > 0) {
Address address = addresses.get(0);
String addressText = String.format("%s-%s%s%s%s",
address.getCountryName(), //國家
address.getAdminArea(), //城市
address.getLocality(), //區
address.getThoroughfare(), //路
address.getSubThoroughfare() //巷號
);
addressedittext.setText(addressText);
}
}
}
});
//現在用於接收不斷的位置更新:
createLocRequest();
LocationSettingsRequest.Builder builder = new LocationSettingsRequest.Builder()
.addLocationRequest(mRequest);
//這會檢查 GPS 模式(高精度、省電、僅限設備)是否設置了適當的“mRequest”。
//如果當前設置不能滿足mRequest(Google Fused Location Provider 自動確定),然後
//我們偵聽失敗並顯示一個對話框供用戶輕鬆更改這些設置。
SettingsClient client = LocationServices.getSettingsClient(MainActivity.this);
Task task = client.checkLocationSettings(builder.build());
task.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
if (e instanceof ResolvableApiException) {
//通過向用戶顯示一個對話框。位置設置不滿足,但是可以修復。
try {
//通過調用 startResolutionForResult() 顯示對話框,
//並在 onActivityResult() 中檢查結果。
ResolvableApiException resolvable = (ResolvableApiException) e;
resolvable.startResolutionForResult(MainActivity.this, 500);
} catch (IntentSender.SendIntentException sendEx) {
//忽略錯誤。
}
}
}
});
}
//實際上開始監聽更新:使用 Resume(),可以便我們從 onPause中回復監聽
@Override
protected void onResume() {
super.onResume();
startLocationUpdates();
}
@Override
protected void onPause() {
super.onPause();
fusedClient.removeLocationUpdates(mCallback);
}
@SuppressLint("MissingPermission")
protected void startLocationUpdates() {
fusedClient.requestLocationUpdates(mRequest, mCallback, null);
}
@SuppressLint("RestrictedApi")
protected void createLocRequest() {
mRequest = new LocationRequest();
mRequest.setInterval(10000);//以毫秒為單位的時間;每 10 秒
mRequest.setFastestInterval(5000);
mRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
switch (requestCode) {
case 2: {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(this, "謝謝您!", Toast.LENGTH_SHORT).show();
locationWizardry();
} else {
Toast.makeText(this, "來吧各位,我們真的需要這個!", Toast.LENGTH_SHORT).show();
}
}
break;
default:
break;
}
}
private void showRationale() {
AlertDialog dialog = new AlertDialog.Builder(this).setMessage("我們需要這個並授予我們" +
"許可權限 :)").setPositiveButton("確定", (dialogInterface, i) ->
{
ActivityCompat.requestPermissions(this, new String[]{android.Manifest.permission.ACCESS_COARSE_LOCATION}, 2);
dialogInterface.dismiss();
})
.create();
dialog.show();
}
@JavascriptInterface
public String GetLat()
{
return Lat;
}
@JavascriptInterface
public String GetLon()
{
return Lon;
}
private String Lat;
private String Lon;
private AlertDialog alerdialog;
View.OnClickListener Click= new View.OnClickListener ()
{
@Override
public void onClick(View view)
{
switch(view.getId())
{
case R.id.buttonmap:
latedittext = (EditText) findViewById(R.id.editTextLat);
lonedittext = (EditText) findViewById(R.id.editTextLon);
Lat = latedittext.getText().toString();
Lon = lonedittext.getText().toString();
if (Lat != null && Lon != null)
{
AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
LayoutInflater inflater = LayoutInflater.from(MainActivity.this);
View mapview = inflater.inflate(R.layout.map,null);
builder.setView(mapview);
ImageView imageview = (ImageView)mapview.findViewById(R.id.imageviemap);
imageview.setOnClickListener(Click);
String mapurl = "file:///android_asset/MaskMap.html";
WebView webview = (WebView) mapview.findViewById(R.id.webviewmap);
webview.getSettings().setJavaScriptEnabled(true);
webview.addJavascriptInterface(MainActivity.this , "AndroidFunction");
webview.loadUrl(mapurl);
alerdialog = builder.show();
}
break;
case R.id.imageviemap:
alerdialog.cancel();
break;
}
}
};
}
- 執行結果如下:
完整程式碼