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; } } }; }
- 執行結果如下:


完整程式碼