2024-08-28

【Kotlin】隨機取出指定筆數的記錄

題目:

有一含 1000 筆記錄的資料表 table1,要從中取出任意 100

在詢問 Google 的 Gemini、OpenAI 的 ChatGPT、Perplexity 的 Perplexity、微軟的 Copilot,多認識了一些類別及技巧,故做此筆記。


做法一:

 
val mScope = CoroutineScope(Job() + Dispatchers.IO)
mScope.launch {
    val mDbHelper = SQLite(baseContext)
    var db = mDbHelper.readableDatabase

    // 查詢本機資料庫有幾筆記錄
    var sSql = "SELECT * FROM table1 "
    val rs = db.rawQuery(sSql, null)
    val mRecnt = rs.count    // 本機資料庫記錄筆數
    var mSentence = 100   // 迴圈數, 要取出 100 筆記錄

    val mList = mutableListOf<Int>()
    var xx = 1
    // 這種做法, 有可能亂數取得的 mPos 已存在 mList 中
    while(xx<=mSentence) {
        val mPos = (0 until mRecnt).random()    // 每筆記錄在 table1 的位置
        if(mPos !in mList) {
            mList.add(mPos)
            xx++
        }
    }
    mList.sorted()    // 將 mList 內的元素由小到大排序
    // 如此, 在實際自 table1 取出記錄就會是自資料表首一路向資料表尾依序取出記錄
    
    ...
    ...
}
 



做法二:

 
val mScope = CoroutineScope(Job() + Dispatchers.IO)
mScope.launch {
    ...
    ...

    val mRaw = mutableSetOf<Int>()    // mutableSet 的特性是存入時就會檢查每個元素的值都是唯一
    val mRandom = Random(System.currentTimeMillis())
    while(mRaw.size<mSentence) {
        mRaw.add(mRandom.nextInt(mRecnt))
    }
    // 但 mutableSet 不具排序功能, 
    // 故將它轉型態成 list 並排序後存入  mList
    val mList = mRaw.toList().sorted()
    
    ...
    ...
}
 



做法三:

 
val mScope = CoroutineScope(Job() + Dispatchers.IO)
mScope.launch {
    ...
    ...

    // 這個做法最快, 一行指令搞定
    // 以 shuffled() 洗牌後, 再以 take() 取前面幾個元素, 再將元素排序
    // (0 until mRecnt) 是 IntRange
    val mList = (0 until mRecnt).shuffled().take(mSentence).sorted()
    // val mList = (0 until mRecnt).shuffled(Random(System.currentTimeMillis())).take(mSentence).sorted()   // 可以在 shuffled() 內加上時間當做亂數種子, 增強其隨機性
    
    ...
    ...
}
 



2024-07-13

【網頁程式】土砲打造 -- 依數個地點距離由近至遠規劃行程導航

參考資料 ----


當我們要前往數個地點,想依距離由近至遠排序,規劃開車行程,可以請 Google Cloud 服務,不過這是要收費的。

行程含目的地的停靠點在 10 個點以下,是一種費率,11(含) 以上則會收較高的費用。

本筆記是自行計算及排序停靠點。


您可以服務全開,不過我只使用其中 2 項 -- 取得地理位置(經緯度)導航







建立 GMap 類別
 
<?php 
date_default_timezone_set('Asia/Taipei');
  
// 用來查詢地址的經、緯度
class GMap {
    function getPageData($url) { 
        $ch = curl_init(); 
        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);  
        curl_setopt($ch, CURLOPT_HTTPHEADER, array('Expect: '));
        curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); 
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_NOBODY, false);
        curl_setopt($ch, CURLOPT_FILETIME, true);
        curl_setopt($ch, CURLOPT_REFERER, $url); 
        curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 4);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
        curl_setopt($ch, CURLOPT_USERAGENT, "Mozilla/4.0 (compatible; MSIE 5.01; Windows NT 5.0)");
        $result['data'] = curl_exec($ch);
        curl_close($ch);
          
        return $result;
    }
    
    // 初始化 apikey 金鑰 
    function __construct() {
        $this->apikey = '向 Google map 申請 apikey';
    } 


    // 從 google map 取得地址經緯度
    public function getLngLat($addr='',$sDebug='') {
        $apikey = $this->apikey;
        $url = "https://maps.googleapis.com/maps/api/geocode/json?address=$addr&key=$apikey";
        $geocode = $this->getPageData($url);
        if($sDebug=='debug')
            var_dump($geocode);

        if(isset($geocode['data'])) {
            if(!$geocode["data"])
                // 當 Google map 解析不了時,回應 經/緯度值都是 -1 的虛擬經緯度
                $geocode = '{"results":[{"geometry":{"location":{"lat":-1,"lng":-1}}}]}';
            else
                $geocode = $geocode['data'];
        } else
            // 當 Google map 解析不了時,回應 經/緯度值都是 -1 的虛擬經緯度
            $geocode = '{"results":[{"geometry":{"location":{"lat":-1,"lng":-1}}}]}';

        $output = json_decode($geocode); 
        $latitude = $output->results[0]->geometry->location->lat;
        $longitude = $output->results[0]->geometry->location->lng; 
     
        return array('lat'=>$latitude,'lng'=>$longitude);
    }
}
?>
 


getroute.php,用來依次計算起點至每個目的地的距離,取最近的地址做為下個停靠點,並再以這地址做為下次計算的起點
 
<?php
// 切換至 https
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === "off") 
{
    $location = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    header('HTTP/1.1 301 Moved Permanently');
    header('Location: ' . $location);
    exit;
}

include('gmap.php');

class MyDest {
    public $addr;
    public $len = 0;
    public $lng = -1;
    public $lat = -1;
  
    function __construct($aa) {
        $this->addr = $aa;
    }
}


if(isset($_GET['lat'])) {
    // 接收到起點 經/緯 度
    // $sLat = 22.612058525444247;    // 測試用
    // $sLng = 120.3142669919977;   // 測試用
    $sLat = $_GET['lat'];
    $sLng = $_GET['lng'];
    

    $aDest = array();    // 存放上傳的目的地
    $aSort = array();    // 排序後的目的地

    $gmap = new GMap();
    
    if(isset($_GET['dest1'])) {
        $obj = new MyDest($_GET['dest1']);
        $fLngLat = $gmap->getLngLat($obj->addr);    // 上車地點經緯度
        $obj->lat = $fLngLat["lat"];    // 緯度
        $obj->lng = $fLngLat["lng"];    // 經度
        array_push($aDest, $obj);
    }
    if(isset($_GET['dest2'])) {
        $obj = new MyDest($_GET['dest2']);
        $fLngLat = $gmap->getLngLat($obj->addr);    // 上車地點經緯度
        $obj->lat = $fLngLat["lat"];    // 緯度
        $obj->lng = $fLngLat["lng"];    // 經度
        array_push($aDest, $obj);
    }
    if(isset($_GET['dest3'])) {
        $obj = new MyDest($_GET['dest3']);
        $fLngLat = $gmap->getLngLat($obj->addr);    // 上車地點經緯度
        $obj->lat = $fLngLat["lat"];    // 緯度
        $obj->lng = $fLngLat["lng"];    // 經度
        array_push($aDest, $obj);
    }
    if(isset($_GET['dest4'])) {
        $obj = new MyDest($_GET['dest4']);
        $fLngLat = $gmap->getLngLat($obj->addr);    // 上車地點經緯度
        $obj->lat = $fLngLat["lat"];    // 緯度
        $obj->lng = $fLngLat["lng"];    // 經度
        array_push($aDest, $obj);
    }
    if(isset($_GET['dest5'])) {
        $obj = new MyDest($_GET['dest5']);
        $fLngLat = $gmap->getLngLat($obj->addr);    // 上車地點經緯度
        $obj->lat = $fLngLat["lat"];    // 緯度
        $obj->lng = $fLngLat["lng"];    // 經度
        array_push($aDest, $obj);
    }

    while(count($aDest)>0) {
        foreach($aDest as &$val) {    // 注意:$val 前面有 &
            // 計算【各目的地】距離【起點】的長度
            $url = "https://maps.googleapis.com/maps/api/directions/json?origin=$sLat,$sLng&destination=".urlencode($val->addr)."&key=$apiKey";
            
            // 目的地 destination 參數也可以是經緯度
            $url = "https://maps.googleapis.com/maps/api/directions/json?origin=$sLat,$sLng&destination=".urlencode($val->lat).",".urlencode($val->lng)."&key=$apiKey";
            
            // 若希望路線避開上高速公路, 則可加 avoid=highways 參數
            $url = "https://maps.googleapis.com/maps/api/directions/json?origin=$sLat,$sLng&destination=".urlencode($val->addr)."&avoid=highways&key=$apiKey";
            // 此外, 還可避開 ----
            // tolls:避開收費路段
            // highways:避開高速公路
            // ferries:避開渡輪
            // indoor:避開室內路線(主要用於步行路線)
            
            // 初始化 cURL
            $ch = curl_init();
            curl_setopt($ch, CURLOPT_URL, $url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
            $response = curl_exec($ch);
            curl_close($ch);
    
            // 解析 json 響應
            $data = json_decode($response, true);
            if ($data['status']=='OK') {
                $val->len = $data['routes'][0]['legs'][0]['distance']['value'];
            } else {
                $val->len = -1;
            }
            unset($val);
        }

        // 使用 usort 函數排序陣列,根據 $a 屬性值從小到大排序
        usort($aDest, function($a, $b) {
            return $a->len <=> $b->len;
        });
        // 排序後的第一個物件就是 距離最短的地址
        $minObj = $aDest[0];
        // print_r($aDest);
        array_push($aSort, ['addr' => ($minObj->addr)]);
        
        // 這個地址做為起點
        $sLat = $minObj->lat;
        $sLng = $minObj->lng;
        array_splice($aDest, 0, 1);
    }
    echo json_encode($aSort);   // 回傳依距離排序後的停靠點及目的地
}

?>
 


navi.php
 
<?php
// 切換至 https
if(empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === "off") 
{
    $location = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    header('HTTP/1.1 301 Moved Permanently');
    header('Location: ' . $location);
    exit;
}
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html style="height:100%;">
<head>
    <meta charset="utf-8">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.0.0/jquery.min.js"></script>
</head>
<body>
<br />
<button onclick="startNavigation()" style="font-size:40pt;">巡航</button><br />
<span  style="font-size:40pt;">提醒:在 Android Chrome 開啟本網頁</span><br />
<br />
<br />
<script>
function startNavigation() {
    var aRoute = [
        "高雄市苓雅區四維三路2號",
        "高雄市前鎮區中華五路789號",
        "高雄市鼓山區濱海二路1號",
        "高雄市左營區博愛二路777號",
        "高雄市左營區高鐵路107號"
    ];

    if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition(function(position) {
            var latitude = position.coords.latitude;    // 緯度
            var longitude = position.coords.longitude;  // 經度
            var str = "https://您自己的網址/getroute.php?lat="+latitude+"&lng="+longitude+"&dest1="+aRoute[0]+"&dest2="+aRoute[1]+"&dest3="+aRoute[2]+"&dest4="+aRoute[3]+"&dest5="+aRoute[4];
            // alert('str = '+str);
            // return;

            var destinations = new Array();
            $.getJSON(str, function(res) {
                // alert("res = "+res);
                // 這段是從 client 端向 server 請求查詢
                // server 回傳 json 型態的陣列
                // 陣列內的元素是 addr
                res.forEach(function(element) {
                    destinations.push(element.addr);
                });
                var waypoints = destinations.map(function(destination) {
                    // Google map 規定停靠點以【|】字元分隔
                    // 將 5 個地點以【|】字元分隔
                    return encodeURIComponent(destination);
                }).join("|");
                destination = waypoints.split('|').pop();   // 取得最後【|】右邊的字串做為目的地
                waypoints = waypoints.slice(0, waypoints.lastIndexOf('|')); // 取得最後【|】的左邊字串做為停靠點

                // 構建導航 URL
                var mapsURL = "https://www.google.com/maps/dir/?api=1&origin=" + latitude + "," + longitude + "&destination=" + destination + "&waypoints=" + waypoints + "&travelmode=driving";
                // alert(mapsURL);
                // return;

                // 跳轉到 Google Maps
                window.location.href = mapsURL;
            });
            // return;
            

            
        }, function(error) {
                alert("無法取得地理位置: " + error.message);
            }
        );
    } else {
        alert("瀏覽器不支援地理定位功能。");
    }
}
</script>
<br />
<br />
</body>
</html>

 


排序後,程式呼叫 Android 手機上的 Google Map app 進行導航。

第一次執行這個網頁程式時,會詢問是否同意瀏覽器存取你的地理位置,選擇同意且記住您的選擇,之後執行就不會再跳出詢問視窗了。

測試 Google Chrome、Firefox、Microsoft Edge 這幾個主流瀏覽器
Chrome 效果最佳,直接啟動 Google Map,畢竟都是自家的產品;
Edge 雖可呼叫 Google Map,但詢問視窗一直開著,不會自動關閉
Firefox 最差,啟動了 Google Map,但要導航的地址全不見了!!



另一個有趣的發現:
如果只單純導航前往一個地點,Google Map 並不管目的地是否與您的行車方向順向,只要抵達目的地附近,Google Map 就認為任務完成了。ex:出發點是高雄市中山大學,目的地是高雄女中(雄女),雄女的對面是國軍英雄館,Google 導航只要帶您到國軍英雄館,導航就結束了。
但當設了多個停靠點時,Google Map 就會使命必達,規劃的路線即使繞一圈也會帶您順向抵達雄女。

然後...老人家又好奇了...這個網頁程式在 iPhone 上打開會是什麼結果?
答案是:在 Safari 直接 Deny!! 不給開


相關筆記 ----






2024-07-10

【MySQL】國定假日應用

參考資料 ----


自 政府資料開放平台 下載下來的檔案格式為 csv,內容依序為【西元日期】(格式為 yyyymmdd)、【星期】(國字)、【是否放假】(0=上班, 2=放假)、【備註】 4 個欄位,以逗點【,】分隔。自 01/01 ~ 12/31 一天一列。

比照這個 csv,在資料庫(我採用 MySQL) 中建一個 table,不過我的 table 有多一個【年度】欄位,並將【是否放假】欄位改名為【休假

我需要的只有放假日,也就是【休假】欄位值 = 2,因此先透過 phpMyAdmin 存入資料庫,再將【平日】刪除。

 
-- csv 最前面補加 西元年度欄位

-- 將 csv 上傳存入 holiday_tmp
-- holiday 和 holiday_tmp 差別只在 holiday 有 autoincrement 的 primary key

-- 刪除平日, 只留假日
DELETE FROM holiday_tmp
    WHERE 休假=0

-- 存入 holiday
INSERT INTO holiday
(年度,日期,星期,休假,備註)
SELECT * FROM holiday_tmp

-- 清空 holiday_tmp
DELETE FROM holiday_tmp
 


2024-06-13

【SQLite】以預設值新增一筆記錄

當 CREATE TABLE 的欄位定義有預設值,或無預設值(NULL) 且未限制 NOT NULL 時,可使用下面指令。

 
INSERT  INTO  資料表名  DEFAULT  VALUES;
 

2024-06-05

【Android】RelativeLayout 對齊螢幕中垂線

假設在 RelativeLayout 內放置 1 個 TextView

1. TextView 的中心對齊中垂線:
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:ads="http://schemas.android.com/apk/res-auto"
    tools:context=".MainActivity">

    ...
    ...

    <TextView
        android:id="@+id/TextView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:layout_centerVertical="true"
        android:text="中心對齊" />
		
</RelativeLayout>
 


2. TextView1 左邊對齊中垂線
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:ads="http://schemas.android.com/apk/res-auto"
    tools:context=".MainActivity">

    ...
    ...
	
    <!-- 看不見的中垂線 -->
    <View
        android:id="@+id/vCenterVertical"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_centerInParent="true" />

    <TextView
        android:id="@+id/TextView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toEndOf="@id/vCenterVertical"
        android:text="左邊對齊中垂線" />
		
</RelativeLayout>
 


3. TextView1 右邊對齊中垂線
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:ads="http://schemas.android.com/apk/res-auto"
    tools:context=".MainActivity">

    ...
    ...
	
    <!-- 看不見的中垂線 -->
    <View
        android:id="@+id/vCenterVertical"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_centerInParent="true" />

    <TextView
        android:id="@+id/TextView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toStartOf="@id/vCenterVertical"
        android:text="右邊對齊中垂線" />
		
</RelativeLayout>
 




2024-04-07

【Kotlin】以資源檔自定向量圖 ImageButton(View) 外觀

參考資料 ----


覺得以程式控制 ImageButton 的各種狀態(按下按鈕、disabled...) 時的外觀很麻煩,可以透過設定資源檔的方式達到目的。
由於 ImageButton 繼承自 View,原則上,本筆記的做法可以套用 View(含) 的其他子類別。
 

app 層級 build.gradle(若您的程式沒有用到視圖綁定,這部份可忽略)
 
...
...
 
android {
    
    ...
    ...
    
    buildFeatures {
        viewBinding true
    }
    
    ...
    ...
 


色表 /values/color.xml
 
<?xml version="1.0" encoding="utf-8"?>
<resources>

    ...
    ...
	
    <color name="blue">#0000FF</color>
    <color name="red">#ffff0000</color>
    <color name="brown">#8B4513</color>
    <color name="gray">#ff888888</color>
    <color name="green">#ff00ff00</color>
    <color name="yellow">#FFFF00</color>
	
    ...
    ...
</resources>
 


分別建立 前景(向量圖)背景 在各種狀態時的顏色的資源檔,並置於 /res/color/
要注意:Android 套用資源檔的原則是以最低限度符合條件就採用,所以預設值要放在最後順位

前景 /res/color/image_state.xml,預設一般狀態是 藍色,按下按鈕時是 棕色,disabled 時是 灰色
 
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true"
        android:color="@color/brown" />
    <item android:state_enabled="false"
        android:color="@color/gray" />
    <item android:color="@color/blue" />    ← 預設值要放在最後順位
</selector>
 


背景 /res/color/back_state.xml,無預設值(所以跟 app 的 Theme 設定相同),按下按鈕時是 黃色,disabled 時是 綠色
 
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true"
        android:color="@color/yellow" />
    <item android:state_enabled="false"
        android:color="@color/green" />
</selector> 


隨手找了個向量圖檔 /res/drawable/hand.xml,tint 屬性指向 image_state.xml
(tint 有著色的意思)
 
<vector
    android:height="48dp"
    android:width="48dp"
    android:tint="@color/image_state"    ← 這裡指向 前景 image_state.xml
    android:viewportHeight="24"
    android:viewportWidth="24"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <path
        android:fillColor="@android:color/white"
        android:pathData="M13,24c-3.26,0 -6.19,-1.99 -7.4,-5.02l-3.03,-7.61C2.26,10.58 3,9.79 3.81,10.05l0.79,0.26c0.56,0.18 1.02,0.61 1.24,1.16L7.25,15H8V3.25C8,2.56 8.56,2 9.25,2s1.25,0.56 1.25,1.25V12h1V1.25C11.5,0.56 12.06,0 12.75,0S14,0.56 14,1.25V12h1V2.75c0,-0.69 0.56,-1.25 1.25,-1.25c0.69,0 1.25,0.56 1.25,1.25V12h1V5.75c0,-0.69 0.56,-1.25 1.25,-1.25S21,5.06 21,5.75V16C21,20.42 17.42,24 13,24z"/>
</vector>
 


activity_main.xml,ImageButton 的 backgroundTint 屬性指向 back_state.xml
 
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/Button1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true"
        android:onClick="onButton1Clicked"/>

    <ImageButton
        android:id="@+id/Button2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        app:srcCompat="@drawable/hand"
        android:backgroundTint="@color/back_state"    ← 這裡指向 背景 back_state.xml
        android:clickable="true" />

</RelativeLayout>
 


MainActivity.kt (程式沒什麼功能,只是觀察 Button2enabled / disabled 時的變化)
 

...
...

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
    }

    fun onButton1Clicked(vv: View) {
        binding.Button2.isEnabled = !binding.Button2.isEnabled
    }
}
 


相關筆記 ----


2024-04-06

【Kotlin】刪除指定目錄內的所有檔案

 
class MainActivity : AppCompatActivity() {

    fun onButtonClicked(vv: View) {
        // 刪除內部儲存空間的所有檔案
        delAll(filesDir.toString())
    }

    ...
    ...
    
    fun delAll(sDir: String) {
        val mScope = CoroutineScope(Job() + Dispatchers.IO)
        mScope.launch {
            val directory = File(sDir)
            val files = directory.listFiles()
            for (file in files) {
                file.delete()
            }
        }
    }
}
 


【Kotlin】查詢 Android 可用外部儲存空間

參考資料 ----


Android app,可儲存 app 自身專屬(用) 檔案於 內部儲存空間外部儲存空間,內部儲存空間很小,只適合存放小容量的檔案,若不是有特殊需求,官方建議存於外部儲存空間。

雖然是使用外部儲存空間,但因為僅供 app 自身專用,所以並不須向系統請求權限。

 
class MainActivity : AppCompatActivity() {
    private val TAG = "MainActivity"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
  
        // 查詢磁碟區狀態
        val sExternalStorage = Environment.getExternalStorageState()
        Log.d(TAG, "可用外部空間媒體資訊: $sExternalStorage")    // mounted: 可讀/寫
        // 狀態有: MEDIA_UNKNOWN, MEDIA_REMOVED, MEDIA_UNMOUNTED, MEDIA_CHECKING, MEDIA_NOFS, MEDIA_MOUNTED, MEDIA_MOUNTED_READ_ONLY, MEDIA_SHARED, MEDIA_BAD_REMOVAL, MEDIA_UNMOUNTABLE
  
        // 以前的 Android 因為 ROM 小, 多半會有 SD 卡插槽
        // 現在 ROM 變大了, 也漸漸不提供 SD 卡插槽了, 並模擬出一塊虛擬 ROM
        val externalStorageVolumes: Array<out File> = ContextCompat.getExternalFilesDirs(applicationContext, null)
        Log.d(TAG, "可用外部空間媒體: ${externalStorageVolumes.size} 個")    // 2 個, 1 個實體 SD 卡, 1 個虛擬 ROM
  
        for(xx in externalStorageVolumes.indices) {
            val primaryExternalStorage = externalStorageVolumes[xx]
            Log.d(TAG, "可用外部空間 $xx: $primaryExternalStorage")
            // 可用外部空間 0: /storage/emulated/0/Android/data/[您的 app 完整 package 名]/files, 虛擬 ROM 通常會是第 1 個
            // 可用外部空間 1: /storage/9E1E-5F1E/Android/data/[您的 app 完整 package 名]/files, 實體 SD 卡
        }
  
        if(Environment.getExternalStorageState()==Environment.MEDIA_MOUNTED) {
            // 有外部儲存空間媒體, 且可 讀/寫
            // 官方強烈建議, 若沒有特殊需要, 優先使用第 1 個可用外部空間
            val mFilePath = externalStorageVolumes[0]
            Log.d(TAG, "mFilePath: $mFilePath")    //   /storage/emulated/0/Android/data/[您的 app 完整 package 名]/files
            val mExtdir = File(getExternalFilesDir(null), "Chkspace")

            // 查詢可用空間
            val mScope = CoroutineScope(Job() + Dispatchers.IO)
            mScope.launch {
                var mBytes: Long = 0
                // 1. 由於在 Android8(Oreo) 推出新的 API26,
                // storageManager.getUuidForPath 不能在主執行緒呼叫, 因此要在 coroutine 內執行
                // 2. 舊的 API 在未來會被棄用, 所以要分開處理
                if(Build.VERSION.SDK_INT>=26) {
                    val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
                    val uuid = storageManager.getUuidForPath(externalStorageVolumes[0])
                    try {
                        mBytes = storageManager.getAllocatableBytes(uuid)
                    } catch(err: IOException) {
                        err.printStackTrace()
                    }
                } else {
                    val path = Environment.getDataDirectory()
                    val stat = StatFs(path.path)
                    val blockSize: Long = stat.blockSizeLong
                    val availableBlocks: Long = stat.availableBlocksLong
                    mBytes = availableBlocks * blockSize
                }
                val formatter = DecimalFormat("#,###")
                Log.d(TAG, "可用剩餘外部儲存空間 = ${formatter.format(mBytes)} ")

                if(mBytes>=1024) {
                    val mKB = (mBytes/1024) // 換算為 kb
                    if(mKB>=1024) {
                        val mmMB = (mKB/1024) // 換算為 mb
                        if(mmMB>=1024) {
                            val mGB = (mmMB/1024) // 換算為 gb
                            Log.d(TAG, "可用剩餘外部儲存空間 = $mGB gb")
                        } else
                            Log.d(TAG, "可用剩餘外部儲存空間 = $mmMB mb")
                    } else {
                        Log.d(TAG, "可用剩餘外部儲存空間 = $mKB kb")
                    }
                } else {
                    Log.d(TAG, "可用剩餘外部儲存空間 = $mBytes bytes")
                }
            }
        }
    }
}
 


【Kotlin】Coroutine 複製 assets 的檔案到內部儲存空間

參考資料 ----

 
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

	// 檢查內部儲存空間是否已存在檔案
    val file = File(this.filesDir, "mymusic.mp3")
    if(!file.exists()) {
            copyAssets()
    } else {
        Toast.makeText(this, "mymusic.mp3 已複製到私有儲存空間", Toast.LENGTH_LONG).show();
    }
}

...
...

// 複製 mymusic.mp3 至私有儲存空間
private fun copyAssets() {
    val mScope = CoroutineScope(Job() + Dispatchers.IO)
    mScope.launch {
        try {
            val outfile = FileOutputStream(File(filesDir, "mymusic.mp3").path)
            val infile: InputStream = assets.open("mymusic.mp3")
            infile.copyTo(outfile)
            outfile.close()
            infile.close()
        } catch(err: IOException) {
            err.printStackTrace();
        }
    }
}
 


相關筆記 ----


2024-02-26

【Kotlin】disable/enable ImageButton

參考資料 ----


雖然在 layout.xml 中定義 enabled="false"AndroidStudio 並不會報錯,但在 app 執行時並沒有產生作用;官網的 ImageButton 參考頁也沒有找到 enabled 的屬性,似乎只能在 app 執行時設定。
 
...
...

<ImageButton
    android:id="@+id/Button1"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/圖檔"    本次採用 向量圖檔(vector asset), 所以可在程式中改變圖檔的顏色
    android:background="@android:color/transparent"    指定按鈕的背景為透明
    android:enabled="true" />    這個屬性在 app 執行時並沒有產生作用
 


 
 
... 
...

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        var button1 = findViewById<ImageButton>(R.id.Button1)
        button1.isEnabled = false
        // 即使被 disabled 了, button1 所引用的圖檔顏色並沒有任何變化
        // 所以也要自行改變圖檔的顏色
        button1.setColorFilter(getColor(R.color.black))
    }