Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(11)-計算小費

瞭解如何編寫與小費計算機應用程式中 UI 元素互動的 Kotlin 程式碼,方便你計算小費。

學習目標

  • Android 應用程式的基本結構。
  • 如何讀取 UI 中的值,將這些值寫入程式碼並進行操控。
  • 如何使用資料檢視繫結 (而不是 findViewById()) 來更輕鬆地編寫與檢視畫面互動的程式碼。
  • 如何搭配 Double 資料類型使用 Kotlin 中的十進位數字。
  • 如何將數字的格式設定為貨幣。
  • 如何使用字串參數來動態建立字串。
  • 如何在 Android Studio 中使用 Logcat 來找出應用程式的問題。

範例應用程式總覽

Tip Time 應用程式會提供小費計算機所需的所有 UI,但不顯示用於計算小費的程式碼。系統顯示「Calculate」按鈕,但該按鈕尚無法正常運作。

  • 使用者可以使用「Cost of ServiceEditText 輸入服務費用。
  • RadioButtons 清單可讓使用者選取小費百分比,
  • Switch 可讓使用者選擇是否應將小費四捨五入。
  • 小費金額顯示在 TextView
  • CalculateButton 會通知應用程式從其他欄位取得資料並計算小費金額。
應用程式專案結構

IDE 中的應用程式專案由多個部分組成,包括 Kotlin 程式碼、XML 版面配置,以及字串和圖片等其他資源。

  1. 在 Android Studio 中開啟 Tip Time 專案。
  2. 如果系統未顯示「Project」視窗,請選取 Android Studio 左側的「Project」分頁標籤。
  3. 從下拉式選單中選擇 Android 檢視畫面 (如果尚未選取該檢視畫面)。
  • Kotlin 檔案 (或 Java 檔案) 的 java 資料夾
  • MainActivity - 小費計算機邏輯的所有 Kotlin 程式碼所屬的類別
  • 應用程式資源的 res 資料夾
  • activity_main.xml - Android 應用程式的版面配置檔案
  • strings.xml - 包含 Android 應用程式的字串資源
  • Gradle Scripts 資料夾

Gradle 是 Android Studio 使用的自動建構系統。當您變更程式碼、新增資源或對應用程式進行其他變更時,Gradle 會判斷變更的內容,並採取必要步驟來重新建構應用程式。它還會在模擬器或實體裝置上安裝您的應用程式,並控管其執行作業。


檢視區塊繫結(Binding)

為了計算小費,程式碼必須存取所有 UI 元素才能讀取使用者的輸入內容。程式碼必須先找到 View 的參照 (例如 ButtonTextView),然後才能呼叫 View 上的方法或存取其屬性。

Android 架構提供 findViewById() 方法,可根據您的需求執行動作,即在指定 View ID 的情況下傳回其參照)。這種方法有用,但隨著您在應用程式中新增更多檢視畫面,而 UI 也變得更加複雜,使用 findViewById() 可能有點麻煩。

為方便起見,Android 還提供一項名為**檢視繫結(Binding)**的功能。只要提前多做一點工作,檢視繫結就能讓您在 UI 的檢視畫面中更輕鬆、更快速地呼叫方法。您必須在 Gradle 中為應用程式啟用檢視繫結,並對程式碼進行一些變更。

啟用檢視繫結
  1. 開啟應用程式的 build.gradle 檔案 build.gradle.kts (:app)
  2. android 部分中,新增以下行:
1
2
3
buildFeatures {
viewBinding = true
}
  1. 注意以下訊息:「Gradle files have changed since last project sync」。
    按下「Sync Now」。
  2. 按下「Sync Now」。

片刻過後,您應該會在 Android Studio 視窗底部看到訊息「Gradle sync finished」。

初始化繫結物件

在先前的程式碼研究室中,您已經看到屬於 MainActivity 類別的 onCreate() 方法。這是應用程式啟動並初始化 MainActivity 時最先呼叫的內容之一。您將建立並初始化繫結物件一次,而無須為應用程式中的每個 View 呼叫 findViewById()

  1. 開啟 MainActivity.kt (依序點選「app」>「java」>「com.example.tiptime」>「MainActivity」)。
  2. MainActivity 類別的所有現有程式碼替換為此程式碼,設定 MainActivity 以使用檢視繫結。

原程式碼:

1
2
3
4
5
6
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}

改成Binding:

1
2
3
4
5
6
7
8
9
10
class MainActivity : AppCompatActivity() {

lateinit var binding: ActivityMainBinding //創建延遲初始化的binding變數

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) //將xml(activity_main.xml)實例化為相應的View物件,並使用binding存取View物件
setContentView(binding.root) //設定activity檢視畫面:使用binding.root代替資源ID(R.layout.activity_main)
}
}
  1. 此行在繫結物件的類別中宣告頂層變數。之所以在此層級定義該變數,是因為將在 MainActivity 類別的多種方法中使用該變數。
1
lateinit var binding: ActivityMainBinding

點此查看如何創建binding classes

lateinit 關鍵字是全新內容。應保證程式碼先初始化變數,然後再使用該變數。否則,您的應用程式將當機。

lateinit:延遲初始化
當我們要綁定一個 TextView,必須先對 textView 這個變數指定 null,進行初始化;後續在指定 textViewtext 屬性時,也要先確認 textView 是不為空的。

1
2
3
4
5
6
7
8
9
10
11
class MainActivity : AppCompatActivity() {

private var textView: TextView? = null

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

textView = findViewById(R.id.textView)
}
}

如果你能確定 textView 這個變數在後續一定會被初始化,也不想每次都用問號來判斷變數是否為空,那你可以加個 lateinit 關鍵字來修飾這個變數:

1
2
3
4
5
6
7
8
9
10
11
12
lass MainActivity : AppCompatActivity() {

private lateinit var textView: TextView

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

textView = findViewById(R.id.textView)
textView.text = "hello world"
}
}
  1. 此行會初始化 binding 物件,您將使用該物件存取 activity_main.xml 版面配置中的 Views
1
binding = ActivityMainBinding.inflate(layoutInflater)
  • LayoutInflater:將layout XML(activity_main.xml) 文件實例化為其相應的 View 物件。
  1. 設定Activity的內容檢視畫面。此行將指定應用程式中檢視畫面階層的根層級 binding.root,而不是傳遞版面配置 R.layout.activity_main 的資源 ID。
1
setContentView(binding.root)

回想父檢視畫面和子檢視畫面的概念;root(根)層級可連結所有這些檢視畫面。

  • 現在應用程式中需要 View 的參照時,可從 binding 物件中取得,而無須呼叫 findViewById()
  • binding 物件會自動為應用程式中每個擁有 ID 的 View 定義參照。
  • 使用檢視繫結這種方式更為簡潔,通常您甚至無須建立變數來保留 View 的參照,只要直接在繫結物件中使用該變數即可。
1
2
3
4
5
6
7
8
9
10
// Old way with findViewById()
val myButton: Button = findViewById(R.id.my_button)
myButton.text = "A button"

// Better way with view binding
val myButton: Button = binding.myButton
myButton.text = "A button"

// Best way with view binding and no extra variable
binding.myButton.text = "A button"

計算小費

使用者輕觸「Calculate」按鈕時,系統開始計算小費。須執行的動作包括檢查 UI,瞭解具體服務費用以及使用者想給的小費百分比。運用這些資訊,您可以計算服務費用的總金額並顯示小費金額。

將點擊事件監聽器新增至按鈕

第一步是新增點擊事件監聽器,以指定使用者輕觸「Calculate」按鈕時,該按鈕應執行的操作。

  1. 呼叫 setContentView() 之後,在「Calculate」按鈕上設定點擊事件監聽器,並讓它呼叫 calculateTip()
1
binding.calculateButton.setOnClickListener{ calculateTip() }
  1. 仍在 MainActivity 類別中 (但在 onCreate() 之外),新增名為 calculateTip() 的函式。在這裡新增程式碼,檢查 UI 並計算小費。
1
2
3
fun calculateTip() {

}
取得服務費用

如要計算小費,首先須計算服務費用。文字會儲存在 EditText 中,但您必須使用它做為數字,以便用於計算。您可能還記得其他程式碼研究室中介紹過的 Int 類型,但 Int 只能保留整數。如要在應用程式中使用十進位數字,請使用名為 Double 的資料類型,而並非 Int
如要進一步瞭解 Kotlin 中的數字資料類型,請參閱説明文件。Kotlin 提供用於將 String 轉換為 Double 的方法 (稱為 toDouble())。

  1. 首先,請取得服務費用的文字。在 calculateTip() 方法中,取得「Cost of Service」EditText的文字屬性,然後指派給稱為 stringInTextField 的變數。
    (請記住,您可以使用 binding 物件存取 UI 元素,還可以根據 UI 元素採用駝峰式大小寫形式的資源 ID 來參照 UI 元素。)
1
val stringInTextField = binding.costOfService.text //抓取服務費用的文字

請注意結尾處的 .text。第一部分,binding.costOfService 參照服務費用的 UI 元素。在結尾處加上 .text 表示要取得該結果 (EditText 物件) 並從中取得 text 屬性。這就是所謂的鏈結,是 Kotlin 中一種很常見的模式。

  1. 接下來,將文字轉換為十進位數字。在 stringInTextField 上呼叫 toDouble(),並儲存在名為 cost 的變數中。
1
val cost = stringInTextField.toDouble() //將stringInTextField轉成Double

透過在 Editable 上呼叫 toString() 將其轉換為 String

  1. binding.costOfService.text 上呼叫 toString() 並將其轉換為 String
1
val stringInTextField = binding.costOfService.text.toString() //抓取服務費用的文字並轉成String

現在,stringInTextField.toDouble() 可以正常運作。

此時,calculateTip() 方法應如下所示:

1
2
3
4
fun calculateTip() {
val stringInTextField = binding.costOfService.text.toString() //抓取服務費用的文字並轉成String
val cost = stringInTextField.toDouble() //將stringInTextField轉成Double
}
取得小費百分比

到目前為止,您已取得服務費用。現在您需要使用者從 RadioButtonsRadioGroup 中選取的小費百分比。

  1. calculateTip() 中,取得 tipOptions RadioGroupcheckedRadioButtonId 屬性,並將其指派給名為 selectedId 的變數。
1
val selectedId = binding.tipOptions.checkedRadioButtonId

現在您知道選取哪個 RadioButton (R.id.option_twenty_percentR.id.option_eighteen_percentR.id.fifteen_percent 其中之一),但還需要相應的百分比。您可以編寫一系列 if/else 陳述式,但使用 when 運算式會簡單許多。

新增下列行即可取得小費百分比。

1
2
3
4
5
val tipPercentage = when (selectedId) {
R.id.option_twenty_percent -> 0.20
R.id.option_eighteen_percent -> 0.18
else -> 0.15
}

此時,calculateTip() 方法應如下所示:

1
2
3
4
5
6
7
8
9
10
fun calculateTip() {
val stringInTextField = binding.costOfService.text.toString()
val cost = stringInTextField.toDouble()
val selectedId = binding.tipOptions.checkedRadioButtonId
val tipPercentage = when (selectedId) {
R.id.option_twenty_percent -> 0.20
R.id.option_eighteen_percent -> 0.18
else -> 0.15
}
}
計算小費並四捨五入

現在您已得知服務費用和小費百分比,要計算小費就十分容易:只要將費用乘以小費百分比,即可計算出小費,即「小費 = 服務費用 *小費百分比」。您可以視需要將該值四捨五入。

  1. calculateTip() 中您新增的其他程式碼後面,將 tipPercentage 乘以 cost,然後將計算出的值指派給名為 tip 的變數。
1
var tip = cost * tipPercentage

請注意,使用 var 而非 val。這是因為使用者選取該選項時,您可能需要將這個值四捨五入,因此值可能會有所異動。

如果是 Switch 元素,請檢查 isChecked 屬性,確認切換按鈕是否「開啟」。
2. 將四捨五入切換按鈕的 isChecked 屬性指派給名為 roundUp 的變數。

1
val roundUp = binding.roundUpSwitch.isChecked

字詞「四捨五入」是指將小數點四捨五入至最接近的整數值,但在這種情況下,您只會無條件進位或無條件進位至最接近的指定基數倍數。您可以使用 ceil() 函式來執行這項操作。有數個函式都採用該名稱,但您需要的是在 kotlin.math 中定義的函式。您可以新增 import 陳述式,但在這種情況下,較簡單的做法是直接使用 kotlin.math.ceil() 告知 Android Studio 您所需的函式。

  1. 新增 if 陳述式,用於在 roundUptrue 時將小費上限指派給 tip 變數。
1
2
3
if(roundUp) {
tip = kotlin.math.ceil(tip)
}
設定小費格式

您的應用程式幾乎可以正常運作。您已計算小費,現在只要設定小費格式並加以顯示即可。

正如您所預期的那樣,Kotlin 提供用於為不同類型的數字設定格式的方法。但小費金額會略有不同,它代表貨幣價值。不同的國家/地區使用不同的貨幣,並且在設定十進位數字的格式方面有不同的規則。例如,以美元為單位時,1234.56 的格式應為 $1,234.56 美元,但以歐元為單位時,格式應為 €1.234,56 歐元。幸好,Android 架構提供用於將數字的格式設定為貨幣的方法,因此您不必瞭解所有可能的做法。系統會根據使用者在手機上選擇的語言和其他設定,自動設定貨幣格式。如要進一步瞭解數字格式,請參閱 Android 開發人員說明文件。

  1. calculateTip() 中的其他程式碼後面,呼叫 NumberFormat.getCurrencyInstance()
1
NumberFormat.getCurrencyInstance()

系統會為您提供數字格式設定工具,以便您將數字格式設為貨幣。

  1. 使用數字格式設定工具時,可將 format() 方法的呼叫鏈結至 tip,並將結果指派給名為 formattedTip 的變數。
1
val formattedTip = NumberFormat.getCurrencyInstance().format(tip)

從可能的匯入內容清單中選擇 **NumberFormat (java.text)**。

顯示小費

現在,您必須在應用程式的小費金額 TextView 元素中顯示小費。您可以將 formattedTip 指派給 text 屬性,不過最好能夠加上標籤説明金額代表的意義。在使用英文的美國,系統可能會顯示「Tip Amount: $12.34」,但使用其他語言時,數字可能需要出現在字串開頭甚或中間。Android 架構提供名為「字串參數」的機制,讓翻譯應用程式的人可視需要變更數字的顯示位置。

  1. 開啟 strings.xml (「app」>「res」>「values」>「strings.xml」)
  2. tip_amount 字串從 Tip Amount 變更為 Tip Amount: %s
1
<string name="tip_amount">Tip Amount: %s</string>

%s 插入已設定格式的貨幣。

  1. 現在請設定 tipResult 的文字。返回 calculateTip() 方法,呼叫 getString(R.string.tip_amount, formattedTip),然後將其指派給小費結果 TextViewtext 屬性。
1
binding.tipResult.text = getString(R.string.tip_amount, formattedTip)

此時,calculateTip() 方法應如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun calculateTip() {
val stringInTextField = binding.costOfService.text.toString()
val cost = stringInTextField.toDouble()
val selectedId = binding.tipOptions.checkedRadioButtonId
val tipPercentage = when (selectedId) {
R.id.option_twenty_percent -> 0.20
R.id.option_eighteen_percent -> 0.18
else -> 0.15
}
var tip = tipPercentage * cost
val roundUp = binding.roundUpSwitch.isChecked
if (roundUp) {
tip = kotlin.math.ceil(tip)
}
val formattedTip = NumberFormat.getCurrencyInstance().format(tip)
binding.tipResult.text = getString(R.string.tip_amount, formattedTip)
}

開發應用程式 (以及檢視預覽畫面) 時,建議您為該 TextView 設定預留位置。

  1. 開啟 activity_main.xml (依序點選「App」>「Res」>「Layout」>「activity_main.xml」)。
  2. 找出 tip_result TextView
  3. 移除包含 android:text 屬性的那一行。
  4. 新增設定為 tools:text 屬性的 Tip Amount: $10
1
tools:text="Tip Amount: $10"

由於這是預留位置,因此無須將字串擷取至資源。執行應用程式時,它不會顯示。

最後,執行應用程式。輸入費用金額並選取一些選項,然後按「Calculate」按鈕。


測試並偵錯

如果使用者未輸入任何文字,stringInTextField 顯示空白,會發生什麽?

  1. 在模擬器中執行應用程式,但使用「Run」>「Debug ‘app’」,而不是使用「Run」>「Run ‘app’」。
  2. 請嘗試費用、小費金額以及小費是否四捨五入的不同組合,並確認您在各種情況下輕觸「Calculate」時是否能夠取得預期結果。
  3. 現在,請嘗試刪除「Cost of Service」欄位中的所有文字,然後輕觸「Calculate」。糟糕,您的程式已當機。

在空字串或不代表有效十進位數字的字串上呼叫 toDouble() 時,將無法運作。幸好,Kotlin 也提供名為 toDoubleOrNull() 的方法,可用於處理這些問題。如果可以,系統會傳回十進位數字;如果發生問題,系統會傳回 null

  1. calculateTip() 中,變更宣告 cost 變數的那一行,呼叫 toDoubleOrNull() 而非呼叫 toDouble()
1
val cost = stringInTextField.toDoubleOrNull()
  1. 在此行後面加入陳述式,檢查 cost 是否為 null,如果是,系統會從該方法傳回。return 指令表示在不執行其餘指令的情況下結束方法。如果系統必須傳回一個值,您應使用包含運算式的 return 指令來指定該值。
1
2
3
if (cost == null) {
return
}
  1. 再次執行應用程式。
  2. 如果「Cost of Service」欄位中沒有任何文字,請輕觸「Calculate」。這次,應用程式並未當機!做得好 — 您已找到並修正錯誤!
處理其他情況

如果使用者執行以下操作:

  1. 輸入服務費用的有效金額
  2. 輕觸「Calculate」以計算小費
  3. 刪除服務費用
  4. 再次輕觸「Calculate」?

第一次,系統會按預期計算並顯示小費。第二次,由於您剛剛新增的檢查,calculateTip() 方法會提早傳回結果,但應用程式仍會顯示先前的小費金額。這可能會讓使用者感到困惑,因此建議新增一些程式碼,以便在出現問題時清除小費金額。

  1. 如要確認發生這個問題,請輸入有效的服務費用,然後輕觸「Calculate」(計算),接著刪除文字後再次輕觸「Calculate」(計算)。系統仍會顯示第一次的小費值。

  2. 在剛剛新增的 if 中,在 return 陳述式之前新增一行,用於將 tipResulttext 屬性設為空字串。

1
2
3
4
if (cost == null) {
binding.tipResult.text = ""
return
}

這樣,系統會在從 calculateTip() 傳回結果之前清除小費金額。

  1. 再次執行應用程式,然後嘗試處理上述情況。再次輕觸「Calculate」時,第一次的小費值應該會消失。

參考資料

Kotlin使用心得(十一):lateinit vs lazy