# Android Programming - Lecture 2 ###### tags: `Kotlin Programming` ## Android Layout Android 的 Layout 以 `View` 和 `ViewGroup` 以階層式的架構組成。 * `View` 為使用者可見的元件,如:`Button`、`TextView`、`ImageView`等。 * `ViewGroup` 則為各種 `Layouts` 如:`LinearLayout`、`ConstrainLayout`、`ScrollView`等。 一個使用者介面中可能會是以下的架構: ![](https://i.imgur.com/PE6GPbA.png) 來源:https://developer.android.com/guide/topics/ui/declaring-layout ### Linear Layout * 在裡面的元件依順序一個接一個的排序。 * 有水平與垂直兩種模式,可透過`xml=android:orientation="vertical"`調整。 #### Layout Weight 可設定元件對畫面佔的程度,較大的數字可以分配填滿剩餘的空間,默認的`layout_weight`為0。 1. 都不設定 若都不設定`layout_weight`,我們會看到依照元件的順序以及預設大小排序。 ```xml= <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" > <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="to" /> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="subject" /> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="message" /> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:text="send" /> </LinearLayout> ``` ![](https://i.imgur.com/lokIvaz.png) 2. 平均分配 若要讓各個元件大小平均分配並填滿,我們可以設定每個元件的`layout_weight`為1。 ```xml= <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_weight="1" android:hint="to" /> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_weight="1" android:hint="subject" /> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_weight="1" android:hint="message" /> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_weight="1" android:text="send" /> </LinearLayout> ``` ![](https://i.imgur.com/14qNe2d.png) 3. 分配不均 但正常情況下我們會希望 Message 的欄位大一點,其他依照正常大小,我們可以單獨設 Message 欄位的`layout_weight=1`。 ```xml= <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="to" /> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="subject" /> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_weight="1" android:hint="message" /> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:text="send" /> </LinearLayout> ``` ![](https://i.imgur.com/tCC70cC.png) 參考:https://developer.android.com/guide/topics/ui/layout/linear ### Constraint Layout * 透過自己與其他元件的 Constraint 決定位置。 * 複雜的界面時可以不用多層 Layout 嵌套處理。 * 每個元件至少需要一個水平和一個垂直 Constraint。 例如圖中的`BUTTON1`與`BUTTON2`,`BUTTON1`有著水平(左右)與垂直(上)`layout_marginTop="200dp"`的 Constraint 因此沒問題,但`BUTTON2`只有水平(與`BUTTON1`水平對齊)的 Constraint,因此在實際執行時就會`BUTTON2`就會飛上去。 ![](https://i.imgur.com/Zm0HT3x.png) ![](https://i.imgur.com/NgkwMLO.png) 可藉由看 Component Tree 部分有無錯誤檢查。 ![](https://i.imgur.com/AhoTgue.png) 接著讓我們來實際使用 Constraint Layout 1. 開啟空的 Constraint Layout ```xml= <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/linearLayout" android:layout_width="match_parent" android:layout_height="match_parent"> </androidx.constraintlayout.widget.ConstraintLayout> ``` 2. 接著切換到圖形編輯模式,並放置一顆 Button,按鈕旁邊四角以及右邊色框框中可以來設定 Constraint。 ![](https://i.imgur.com/hIKebdW.png) 3. 我們可以透過紅色框框中`+`與`x`符號新增與刪除 Constraint,新增 Constraint 後可透過旁邊下拉式選單選擇對應的`margin`大小。 ![](https://i.imgur.com/Y8DIOvL.png) 4. 設定完對應的`margin`後可以調整標註上的符號來更改高度和寬度的計算方式。 ![](https://i.imgur.com/FHIyefq.png) 詳細資料參考:https://developer.android.com/training/constraint-layout#adjust-the-view-size 5. 鍊式約束(chain) 可以把幾個元件之間連接起來,類似於 Linear Layout。 把元件們選起來,`右鍵`->`Chains`->`Create Horizontal Chain`,接著`右鍵`->`Align`->`Top Edges`建立 Chain。 ![](https://i.imgur.com/8duZCYI.png) Chain 有以下幾種模式: 1. Spread:各個元件會平均的分怖在 layout 上。 2. Spread inside:頭尾的元件會固定在約束範圍端,其它的則會平均分怖。 3. Packed:個元件會都聚集在一起並置中。 ![](https://i.imgur.com/FyxxJSb.png) 參考:https://developer.android.com/training/constraint-layout#constrain-chain ## Wiki Search APP 接著我們練習分以`Constrain Layout`實做一個搜尋 Wiki 頁面摘要的 APP: https://en.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro&explaintext&titles=Google ### 建立專案 1. Create New Project。 ![](https://i.imgur.com/EgVw0Aa.png) 2. Template 選擇 Empty Activity。 ![](https://i.imgur.com/t01siPx.png) 3. 命名為 Wiki Search,語言選擇 kotlin ,Minimum SDK 選擇 Android 11.0。 ![](https://i.imgur.com/1fq29rb.png) ### 建立 Layout 可自行練習拉元件或在 activity_main.xml 貼上以下 XML 再至圖形編輯介面觀察與調整。 ![](https://i.imgur.com/yAXDa6i.png) MainActivity.kt: ```xml= <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout 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"> <EditText android:id="@+id/edit_text_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:autofillHints="" android:ems="10" android:hint="Title" android:inputType="textPersonName" app:layout_constraintEnd_toStartOf="@+id/button_search" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/button_search" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Search" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toEndOf="@+id/edit_text_title" app:layout_constraintTop_toTopOf="@+id/edit_text_title" /> <Button android:id="@+id/button_clear" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginBottom="16dp" android:text="Clear" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> <EditText android:id="@+id/edit_text_search_result" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginStart="32dp" android:layout_marginTop="16dp" android:layout_marginEnd="32dp" android:layout_marginBottom="16dp" android:ems="10" android:gravity="start|top" android:inputType="textMultiLine" app:layout_constraintBottom_toTopOf="@+id/button_clear" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/edit_text_title" /> </androidx.constraintlayout.widget.ConstraintLayout> ``` ### 綁定元件 (View Binding) 1. 使用 View Binding 前必須先在 build.gradle 中設定 viewBinding 爲 true。 ``` android { ... buildFeatures { viewBinding true } } ``` 2. 在 MainActivity.kt 中對按鈕進行綁定及操作。 ```kotlin= override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) } ``` ### Wiki API 我們可以從 Wiki 的 API 獲取標題的內容,拿取的參數與結果如下: 使用 HTTP GET 方法,參數以 Query String方式,如我們這次 Call 的 API: * https://zh.wikipedia.org/w/api.php 為 API 的 Base URL。 * 接著以`key=value`方式設定參數,如:`format=json`。 * 每筆參數以`&`符號格開,如:`format=json&action=query`。 我們可以先預覽一下 API 拿到的資料是否與一般 Wiki 一樣,這邊以 Google 主題為例: 原頁面:https://zh.wikipedia.org/zh-tw/Google API:https://zh.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exsentences=10&exlimit=1&explaintext=1&formatversion=2&titles=google 返回資料: ```json= { "batchcomplete":true, "query":{ "normalized":[ { "fromencoded":false, "from":"google", "to":"Google" } ], "pages":[ { "pageid":738397, "ns":0, "title":"Google", "extract":"Google(中文譯名:谷歌/科高)是總部位於美國加州門洛帕克的跨國科技公司,為Alphabet Inc.的子公司,業務範圍涵蓋網際網路廣告、網際網路搜尋、雲端運算等領域,開發並提供大量基於網際網路的產品與服務,其主要利潤來自於AdWords等廣告服務。Google由在史丹佛大學攻讀理工博士的賴利·佩吉和謝爾蓋·布林共同建立,因此兩人也被稱為「Google Guys」。\n1998年9月4日,Google以私營公司的形式創立,目的是設計並管理網際網路搜尋引擎「Google搜尋」。2004年8月19日,Google公司在那斯達克上市,後來被稱為「三駕馬車」的公司兩位共同創始人與出任執行長的艾瑞克·施密特在此時承諾:共同在Google工作至少二十年,即至2024年止。Google的宗旨是「匯整全球資訊,供大眾使用,使人人受惠」(To organize the world's information and make it universally accessible and useful);而非正式的口號則為「不作惡」(Don't be evil),由工程師阿米特·帕特爾(Amit Patel)所創,並得到了保羅·布赫海特的支援。Google公司的總部稱為「Googleplex」,位於美國加州聖塔克拉拉縣的山景城。2011年4月,佩吉接替施密特擔任執行長。在2015年8月,Google宣布進行資產重組。重組後,Google劃歸新成立的Alphabet底下。同時,此舉把Google旗下的核心搜尋和廣告業務與Google無人車等新興業務分離開來。" } ] } } ``` 可發現確實有拿到該 Wiki 主題的摘要,接著我們開始寫程式。 在 `com.example.wikisearch` 下新增 `NetworkUtils.kt` 主要分為兩個 method: * buildUrl(wikiSearchTitle: String) 以輸入要搜尋的主提建立並回傳完整 API 的 URL,這邊就是把輸入的`wikiSearchTitle`串進去,最後會長這樣:`https://zh.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exsentences=10&exlimit=1&explaintext=1&formatversion=2&titles=要搜尋的主提`。 ```kotlin= val WIKI_BASE_URL = "https://zh.wikipedia.org/w/api.php" val PARAM_FORMAT = "format" val format = "json" val PARAM_ACTION = "action" val action = "query" val PARAM_PROP = "prop" val prop = "extracts" val PARAM_EXSENTENCES = "exsentences" val exsentences = "10" val PARAM_EXLIMIT = "exlimit" val exlimit = "1" val PARAM_EXPLAINTEXT = "explaintext" val explaintext = "1" val PARAM_FORMATVERSION = "formatversion" val formatversion = "2" val PARAM_TITLES = "titles" fun buildUrl(wikiSearchTitle: String): URL? { val builtUri = Uri.parse(WIKI_BASE_URL).buildUpon() .appendQueryParameter(PARAM_FORMAT, format) .appendQueryParameter(PARAM_ACTION, action) .appendQueryParameter(PARAM_PROP, prop) .appendQueryParameter(PARAM_EXSENTENCES, exsentences) .appendQueryParameter(PARAM_EXLIMIT, exlimit) .appendQueryParameter(PARAM_EXPLAINTEXT, explaintext) .appendQueryParameter(PARAM_FORMATVERSION, formatversion) .appendQueryParameter(PARAM_TITLES, wikiSearchTitle) .build() var url: URL? = null try { url = URL(builtUri.toString()) } catch (e: MalformedURLException) { e.printStackTrace() } return url } ``` * getResponseFromHttpUrl(url: URL) 輸入 URL 並實際發送 HTTP 請求。 ```kotlin= fun getResponseFromHttpUrl(url: URL): String? { val urlConnection = url.openConnection() as HttpURLConnection return try { val `in` = urlConnection.inputStream val scanner = Scanner(`in`) scanner.useDelimiter("\\A") val hasInput = scanner.hasNext() if (hasInput) { scanner.next() } else { null } } finally { urlConnection.disconnect() } } ``` NetworkUtils.kt: ```kotlin= package com.example.wikisearch import android.net.Uri import java.io.IOException import java.net.HttpURLConnection import java.net.MalformedURLException import java.net.URL import java.util.* object NetworkUtils { val WIKI_BASE_URL = "https://zh.wikipedia.org/w/api.php" val PARAM_FORMAT = "format" val format = "json" val PARAM_ACTION = "action" val action = "query" val PARAM_PROP = "prop" val prop = "extracts" val PARAM_EXSENTENCES = "exsentences" val exsentences = "10" val PARAM_EXLIMIT = "exlimit" val exlimit = "1" val PARAM_EXPLAINTEXT = "explaintext" val explaintext = "1" val PARAM_FORMATVERSION = "formatversion" val formatversion = "2" val PARAM_TITLES = "titles" fun buildUrl(wikiSearchTitle: String): URL? { val builtUri = Uri.parse(WIKI_BASE_URL).buildUpon() .appendQueryParameter(PARAM_FORMAT, format) .appendQueryParameter(PARAM_ACTION, action) .appendQueryParameter(PARAM_PROP, prop) .appendQueryParameter(PARAM_EXSENTENCES, exsentences) .appendQueryParameter(PARAM_EXLIMIT, exlimit) .appendQueryParameter(PARAM_EXPLAINTEXT, explaintext) .appendQueryParameter(PARAM_FORMATVERSION, formatversion) .appendQueryParameter(PARAM_TITLES, wikiSearchTitle) .build() var url: URL? = null try { url = URL(builtUri.toString()) } catch (e: MalformedURLException) { e.printStackTrace() } return url } @Throws(IOException::class) fun getResponseFromHttpUrl(url: URL): String? { val urlConnection = url.openConnection() as HttpURLConnection return try { val `in` = urlConnection.inputStream val scanner = Scanner(`in`) scanner.useDelimiter("\\A") val hasInput = scanner.hasNext() if (hasInput) { scanner.next() } else { null } } finally { urlConnection.disconnect() } } } ``` ### 新增 INTERNET Permission 由於使用網路需要`INTERNET`權限,因此我們需在`AndroidManifest.xml`中新增。 ```xml= <uses-permission android:name="android.permission.INTERNET" /> ``` AndroidManifest.xml: ```xml= <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.wikisearch"> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.WikiSearch"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest> ``` ### Coroutines 接下來有需要從網路獲得資料,由於網路存取需要一些時間,而且時間不固定,在 Android 3.0 以後不能夠從主 `Thread` 去進行任何需要很久的工作(網路、檔案I/O等),因此我們需要使用 `Coroutines` 在新的 `Thread` 中執行。 使用時需在`build.gradle`中新增 dependencies。 ``` dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2' } ``` 接著只要寫在`GlobalScope.launch(Dispatchers.IO) {}`中就可以把工作丟到另一個`Thread`中執行。 ### 從 API 拿資料 首先在`MainActivity.kt`的`onCreate`監聽`buttonSearch`的 Click 事件,並執行`makeWikiSearchQuery()`Function。 ```kotlin= binding.buttonSearch.setOnClickListener { makeWikiSearchQuery() } ``` 與`buttonClear`的 Click 事件,清除`editTextSearchResult`上的文字。 ```kotlin= binding.buttonClear.setOnClickListener { binding.editTextSearchResult.setText(""); } ``` 我們先獲得原始回傳資料並顯示在`editTextSearchResult`上,首先先 call `NetworkUtils.buildUrl(wikiQuery)`產生`URL`,再在`Coroutines`中`callNetworkUtils.getResponseFromHttpUrl(wikiSearchUrl)`從網路抓取資料。 ```kotlin= private fun makeWikiSearchQuery() { val wikiQuery = binding.editTextTitle.text.toString() val wikiSearchUrl: URL? = NetworkUtils.buildUrl(wikiQuery) GlobalScope.launch(Dispatchers.IO) { Looper.prepare(); if (wikiSearchUrl != null) { var wikiSearchResult: String? = null try { wikiSearchResult = NetworkUtils.getResponseFromHttpUrl(wikiSearchUrl) } catch (e: IOException) { e.printStackTrace() } binding.editTextSearchResult.setText(wikiSearchResult) } Looper.loop(); } } ``` MainActivity.kt: ```kotlin= package com.example.wikisearch import android.os.Bundle import android.os.Looper import androidx.appcompat.app.AppCompatActivity import com.example.wikisearch.databinding.ActivityMainBinding import kotlinx.coroutines.* import java.io.IOException import java.net.URL private lateinit var binding: ActivityMainBinding class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.buttonSearch.setOnClickListener { makeWikiSearchQuery() } binding.buttonClear.setOnClickListener { binding.editTextSearchResult.setText(""); } } private fun makeWikiSearchQuery() { val wikiQuery = binding.editTextTitle.text.toString() val wikiSearchUrl: URL? = NetworkUtils.buildUrl(wikiQuery) GlobalScope.launch(Dispatchers.IO) { Looper.prepare(); if (wikiSearchUrl != null) { var wikiSearchResult: String? = null try { wikiSearchResult = NetworkUtils.getResponseFromHttpUrl(wikiSearchUrl) } catch (e: IOException) { e.printStackTrace() } binding.editTextSearchResult.setText(wikiSearchResult) } Looper.loop(); } } } ``` 可看到搜尋 Google 獲得了 API (https://zh.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exsentences=10&exlimit=1&explaintext=1&formatversion=2&titles=google) 回傳的原始資料: ![](https://i.imgur.com/JW1r7NR.png) ### 轉換 JSON 為物件 由於回傳的純文字是 JSON 形式,我們希望取出`query.pages[0].extract`內容,因此我們需要將這段 JSON 轉換為程式中的物件,這邊我們使用`GSON`。 使用時需在`build.gradle`中新增 dependencies。 ``` dependencies { implementation 'com.google.code.gson:gson:2.8.6' } ``` 接著我們需建立要讓 JSON mapping 回來的 data class。新增 WikiSearchResult.kt: ```kotlin= package com.example.wikisearch data class WikiSearchResult( val batchcomplete: String, val query: WikiQueryResutQuery ) { data class WikiQueryResutQuery( val normalized: List<WikiQueryResutQueryNormalized>, val pages: List<WikiQueryResutQueryPages> ) { data class WikiQueryResutQueryNormalized( val fromencoded: Boolean, val from: String, val to: String ) data class WikiQueryResutQueryPages( val pageid: Int, val ns: Int, val title: String, val extract: String ) } } ``` 最後我們可以這樣使用: ```kotlin= val gson = Gson() val result = gson.fromJson(wikiSearchResult, WikiSearchResult::class.java) result.query.pages[0].extract // 拿出 extract ``` MainActivity.kt: ```kotlin= package com.example.wikisearch import android.os.Bundle import android.os.Looper import androidx.appcompat.app.AppCompatActivity import com.example.wikisearch.databinding.ActivityMainBinding import com.google.gson.Gson import kotlinx.coroutines.* import java.io.IOException import java.net.URL private lateinit var binding: ActivityMainBinding class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.buttonSearch.setOnClickListener { makeWikiSearchQuery() } binding.buttonClear.setOnClickListener { binding.editTextSearchResult.setText(""); } } private fun makeWikiSearchQuery() { val wikiQuery = binding.editTextTitle.text.toString() val wikiSearchUrl: URL? = NetworkUtils.buildUrl(wikiQuery) GlobalScope.launch(Dispatchers.IO) { Looper.prepare() if (wikiSearchUrl != null) { var wikiSearchResult: String? = null try { wikiSearchResult = NetworkUtils.getResponseFromHttpUrl(wikiSearchUrl) } catch (e: IOException) { e.printStackTrace() } val gson = Gson() val result = gson.fromJson(wikiSearchResult, WikiSearchResult::class.java) binding.editTextSearchResult.setText(result.query.pages[0].extract) } Looper.loop() } } } ``` 最後可以只拿到文章摘要: ![](https://i.imgur.com/aVK1r8T.png)