使用Kotlin&Anko, 扔掉XML開發Android應用


嘗鮮使用Kotlin寫了一段時間Android。說大幅度的減少了Java代碼一點不誇張。用Java的時候動不動就new一個OnClickListener()匿名類,動不動就類型轉換的地方都可以省下很多。更不用說特殊的地方使用data class更是少些不知道多少代碼。

Jetbrains給Android帶來的不僅是Kotlin,還有Anko。從Anko的官方說明來看這是一個雄心勃勃的要代替XML寫Layout的新的開發方式。Anko最重要的一點是引入了DSL(Domain Specific Language)的方式開發Android界面布局。當然,本質是代碼實現布局。不過使用Anko完全不用經歷Java純代碼寫Android的痛苦。因為本身是來自Kotlin的,所以自然的使用這種方式開發就具有了:

  • 類型安全,不再需要那么多的findById()之后的類型轉換。
  • null安全,Kotlin里,如果一個變量用?表示為可空,並且使用?之后再調用的時候,即使變量為空也不會引發異常。
  • 無需設備解析XML,因為Anko本質是代碼實現的界面和布局,所以省去了這些麻煩。
  • 代碼復用,可以通過繼承AnkoComponent的方式實現代碼復用。XML布局是每一個Activity,每一個View各自專屬一個,
    代碼復用比較少。

來一個列子看一下。為了不太墨跡,一些不必要的xml聲明此處略去。

<RelativeLayout>

    <TextView
        android:id="@+id/sample_text_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:text="Sample text view"
        android:textSize="25sp" />

    <Button
        android:id="@+id/sample_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@+id/sample_text_view"
        android:text="Sample button" />

</RelativeLayout>
    relativeLayout {
        val textView = textView("Sample text view") {
            textSize = 25f
        }.lparams {
            width = matchParent
            alignParentTop()
        }

        button("Sample button").lparams {
            width = matchParent
            below(textView)
        }
    }

准備工作

首先,安裝一個Kotlin的插件是必須的。有了這個插件才可以使用Kotlin,然后才可以使用Anko。安裝這個插件和Android Studio里安裝別的插件市一樣的。只需要使用Kotlin查找就可以找到,之后安裝即可。

build.gradle里添加下面的代碼:

dependencies {
    compile 'org.jetbrains.anko:anko-sdk15:0.8.3' // sdk19, sdk21, sdk23 are also available
    compile 'org.jetbrains.anko:anko-support-v4:0.8.3' // In case you need support-v4 bindings
    compile 'org.jetbrains.anko:anko-appcompat-v7:0.8.3' // For appcompat-v7 bindings
}

然后sync一把。配置的問題解決。

寫一個ListView熱身

首先創建一個ListView的item點擊之后跳轉的activity。這里叫做TabDemo1

現在就創建這個listview,並在listview的item點擊之后調轉到相應的activity去。
這個listview非常簡單,只在一個豎直的布局中放置,並且寬度和高度都是填滿豎直
布局。

    // 1
    verticalLayout {
        padding = dip(16)
        // 2
        val list = listView() {
            // 3
            adapter = ArrayAdapter<String>(this@MainActivity, android.R.layout.simple_list_item_1, items)
            // 4
            onItemClickListener = object : AdapterView.OnItemClickListener {
                override fun onItemClick(parent: AdapterView<*>?, v: View?, position: Int, id: Long) {
                    when (position) {
                        0 -> {
                            // 5
                            startActivity<TabDemo1>()
                        }
                    }
                }
            }
        }.lparams(width = matchParent) { // 6
            height = matchParent
        }
    }

分別解釋:

  1. 豎直布局。本質是LinearLayout,並且orientation的值為vertical。但是
    水平方向的就沒有vetialLayout這種可以直接使用的了,需要自己寫明orientation。
  2. 創建一個listview。
  3. 給這個listview添加adapter。這里簡單實用ArrayAdapter<String>
  4. 添加OnItemClickListenerobject : AdapterView.OnItemClickListener用來
    創建實現某個接口的匿名類。
  5. startActivity<TabDemo1>(),是Anko的語法糖。startActivity(SourceActivity.this, DestActivity.class)
    可以直接簡化為startActivity<DestActivity>()。簡單了不少。
  6. lparams中設置layout params相關的內容。默認的都是wrap content。這個設置為
    寬、高都為match parent。

用Fragment寫一個Tab布局

熱身結束。我們來開始真正的開發階段。

下面要開發的是一個日記App。一共有三個tab,第一個是日記列表,第二個tab是寫日記,第三個tab可以設置一些字體大小等(這里只用來占位,不做實現)。

每一個tab都用一個Fragment來展示內容。這三個tab分別HomeListFragment, DetailFragment,DiarySettingsFragment。這個三個fragment都在一個叫做TabDemo1的托管Activity里。

現在就從這個托管activity:TabDemo1開始。這里我們不使用默認的ActionBar,而是用完全自定義的方式來寫一個我們自己的action bar。所以需要把界面設定為全屏模式。設置全屏的模式的方法有很多,我們用設置style的方式來實現。

    <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
    </style>

之后把這個style應用在activity在AndroidManifest.xml配置中。

這個時候這個托管activity的界面布局就是一個完全的白板了。這個白板現在要分為上中下三部分。上部為我們自定義的action bar,最下面的是tab bar,剩下的部分就是每個tab的內容的fragment。

我們來看一下這個布局應該怎么寫:

    // 1
    relativeLayout {
        id = ID_RELATIVELAYOUT

        backgroundColor = Color.LTGRAY

        // 2
        linearLayout {
            id = ID_TOP_BAR
            backgroundColor = ContextCompat.getColor(ctx, R.color.colorPrimary)
            orientation = LinearLayout.HORIZONTAL

            titleTextView = textView {
                text = "Some Title"
                textSize = 16f
                textColor = Color.WHITE
                gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL
            }.lparams {
                width = dip(0)
                height = matchParent
                weight = 1f
            }
        }.lparams {
            width = matchParent
            height = dip(50)
            alignParentTop()
        }

        // 3
        linearLayout {
            id = ID_BOTTOM_TAB_BAR
            orientation = LinearLayout.HORIZONTAL
            backgroundColor = Color.WHITE

            // 4
            homeListTab = weightTextView {
                text = "List"
                normalDrawable = resources.getDrawable(R.mipmap.tab_my_normal)
                selectedDrawable = resources.getDrawable(R.mipmap.tab_my_pressed)
                onClick { tabClick(0) }
            }

            detailTab = weightTextView {
                text = "Detail"
                normalDrawable = resources.getDrawable(R.mipmap.tab_channel_normal)
                selectedDrawable = resources.getDrawable(R.mipmap.tab_channel_pressed)
                onClick { tabClick(1) }
            }

            settingsTab = weightTextView {
                text = "Settings"
                normalDrawable = resources.getDrawable(R.mipmap.tab_better_normal)
                selectedDrawable = resources.getDrawable(R.mipmap.tab_better_pressed)
                onClick { tabClick(2) }
            }

        }.style { // 5
            view ->
            when (view) {
                is TextView -> {
                    view.padding = dip(5)
                    view.compoundDrawablePadding = dip(3)
                    view.textSize = 10f
                    view.gravity = Gravity.CENTER
                }
                else -> {
                }
            }
        }.lparams {
            height = dip(50)
            width = matchParent
            alignParentBottom()
        }

        // 6
        fragmentContainer = frameLayout {
            id = ID_FRAMELAYOUT
            backgroundColor = Color.GREEN
        }.lparams {
            below(ID_TOP_BAR)
            above(ID_BOTTOM_TAB_BAR)
            width = matchParent
            height = matchParent
        }
    }
  1. 前文的例子用了一個verticalLayout, 這里用的是relativeLayout的布局。

  2. 這里是自定義action bar。使用換一個linearLayout。如前所述,要橫向布局linear layout
    就需要單獨的指定orientation:orientation =LinearLayout.HORIZONTAL。這里比較簡單,只有一個顯示title的text view。

    這里需要注意gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL
    可以直接寫成gravity = Gravity.CENTER。這里是為了突出or的用法。Kotlin里的or
    就是java的|操作符的作用。

  3. 這部分的布局是tab bar。

  4. 這里用的是weightTextView而不是textView。后面會詳細的講解這一部分。

  5. 給tab bar添加style。此style不是彼style。這個style,會遍歷tab bar的linear layout內部的全部的view,然后根據when表達式匹配對應的規則,之后給對應於規則的view設置相應的屬性。比如,這里會用when語句查看view是否為textView,如果是的話就給這個view設置padding、drawable padding、text size以及gravity屬性。tab bar的linear layout有三個text view,所以他們都會被設置這些屬性。

  6. 每一個tab的內容展示用fragment就是這里了。准確的說是fragment的container。
    這個container是一個framelayout。在action bar之下,在tab bar之上。在布局的時候有below(ID_TOP_BAR), above(ID_BOTTOM_TAB_BAR)ID_TOP_BARID_BOTTOM_TAB_BAR就分別是action bar和tab bar的id值。這些id值自由設定。

另外,在java寫的時候常用的findViewById()方法在Kotlin和Anko中可以改為的find<FrameLayout>(ID_FRAMELAYOUT)。不見得簡單,但是增加了類型安全。不用再強制類型轉換。也不用擔心相關的錯誤再發生。

上文第4點用到了weightTextView。這是一個自定義的view。在Anko布局中,可以根據自己的需要自定義各種各樣的view。但是,需要經過一個小小的處理之后才可以使用到Anko的布局中。這個小小的處理就叫做擴展。下面看看如何給Anko添加weightTextView擴展的。

首先自定義一個view:WeightTextView

class WeightTextView(context: Context) : TextView(context) {
        var normalDrawable: Drawable? = null
        var selectedDrawable: Drawable? = null

        init {
            var layoutParams = LinearLayout.LayoutParams(dip(50),
                    LinearLayout.LayoutParams.MATCH_PARENT, 1f)
            layoutParams.weight = 1f
            this.layoutParams = layoutParams
        }

        override fun setSelected(selected: Boolean) {
            super.setSelected(selected)

            if (selected) {
                this.backgroundColor = ContextCompat.getColor(context, R.color.textGray)
                this.textColor = ContextCompat.getColor(context, R.color.textYellow)

                if (selectedDrawable != null) {
                    this.setCompoundDrawablesWithIntrinsicBounds(null, selectedDrawable, null, null)
                }
            } else {
                this.backgroundColor = ContextCompat.getColor(context, android.R.color.transparent)
                this.textColor = ContextCompat.getColor(context, R.color.textGray)
                if (normalDrawable != null) {
                    this.setCompoundDrawablesWithIntrinsicBounds(null, normalDrawable, null, null)
                }
            }
        }
    }

附加解釋:
方法setSelected()是被迫添加的。在使用Anko,相當於使用代碼開發Android布局的時候selector不起作用。只好把點擊后的高亮效果寫在自定義的text view里。

下面看看如何擴展Anko,來使用我們上面的自定義view。

    public inline fun ViewManager.weightTextView() = weightTextView {}
    public inline fun ViewManager.weightTextView(init: WeightTextView.() -> Unit) = ankoView({ WeightTextView(it) }, init)

這部分涉及到的語法內容可以參考官網
這里簡單介紹一下。拿官網的例子說一下:

class HTML {
    fun body() { ... }
}

現在有這么一個HTML類,那么調用的時候可以這樣:

html {       
    body()  
}

在這么一個lambda表達式里就可以直接這樣調用HTML類的方法了,中間的過程是怎么樣的呢

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()  // create the receiver object
    html.init()        
    return html
}

其實灰常的簡單呢。在方法html()里,參數是一個HTML類的擴展方法,並且此方法無參,返回Unit(java的void)。

在方法執行的過程中,首先初始化了HTML。之后調用了這個作為參數傳入的擴展方法。在具體調用html()方法的時候,可以只簡單寫一個lambda表達式作為傳入的HTML擴展方法。既然是一個類的擴展方法,那當然可以調用這個類內部的方法了。

為了幫助理解,這里給出一個參數是方法的方法:

fun main(args: Array<String>) {
    calling("yo") { p ->
        println("method called $p")
    }

    calling("yoyo", ::called)
}


fun calling(param: String, func: (String) -> Unit) {
    func(param)
}

fun called(p: String) {
    println("output string $p")
}

第一個是用lambda表達式作為傳入方法,第二個是已經定義好的一個方法作為傳入方法。

Fragment的處理

本文中的重點在於使用Anko做布局,具體的邏輯處理java寫和Kotlin寫沒有什么區別。這里只簡單介紹一下。

為了保證兼容,這里使用Support v4來處理Fragment的顯示等操作。在activity的一開始就把需要的fragemnt都加載進來。

    fun prepareTabFragments() {
        val fm = supportFragmentManager
        homeListFragment = HomeListFragment.newInstance()
        fm.beginTransaction()
                .add(ID_FRAMELAYOUT, homeListFragment)
                .commit()
        detailFragment = DetailFragment.newInstance(null)
        detailFragment?.modelChangeListener = homeListFragment
        fm.beginTransaction()
                .add(ID_FRAMELAYOUT, detailFragment)
                .commit()
        settingsFragment = DiarySettingsFragment.newInstance()
        fm.beginTransaction()
                .add(ID_FRAMELAYOUT, settingsFragment)
                .commit()
    }

每一個tab項被點擊的時候的處理:

    fun tabClick(index: Int) {
        info("index is $index")
        val ft = supportFragmentManager.beginTransaction()
        ft.hide(homeListFragment)
        ft.hide(detailFragment)
        ft.hide(settingsFragment)

        // unselect all textviews
        homeListTab?.isSelected = false
        detailTab?.isSelected = false
        settingsTab?.isSelected = false

        when (index) {
            0 -> {
                homeListTab?.isSelected = true
                ft.show(homeListFragment)
            }
            1 -> {
                detailTab?.isSelected = true
                ft.show(detailFragment)
            }
            2 -> {
                settingsTab?.isSelected = true
                ft.show(settingsFragment)
            }
            else -> {

            }
        }

        ft.commit()
    }

分別開始每一個Fragment

在開始之前需要考慮一個很嚴重的事情:數據存在什么地方。本來應該是SQLite或者存在雲上的。存在雲裳就可以實現同一個賬號登錄在任何地方都可以同步到同樣的內容。這里只簡單模擬,存放在app的內存里。存放在Application派生類AnkoApplication
靜態屬性diaryDataSource里。diaryDataSource是一個ArrayList一樣的列表。

class AnkoApplication : Application() {

    override fun onCreate() {
        super.onCreate()
    }

    companion object {
        var diaryDataSource = mutableListOf<DiaryModel>()
    }
}

第一個tab,HomeListFragment

HomeListFragment類作為第一個tab內容展示fragment,用來顯示全部的日記列表的布局就非常簡單了,和我們前面的例子沒有什么太大的差別。就是在一個verticalLayout里放一個list view。這個list view的data source只需要一個列表。

    // 1
    var view = with(ctx) {
        verticalLayout {
            backgroundColor = Color.WHITE

            listView = listView {
                adapter = ArrayAdapter<DiaryModel>(ctx,
                        android.R.layout.simple_list_item_1,
                        AnkoApplication.diaryDataSource)

                onItemClick { adapterView, view, i, l ->
                    toast("clicked index: $i, content: ${AnkoApplication.diaryDataSource[i].toString()}")
                }
            }

            // 2
            emptyTextView = textView {
                text = resources.getString(R.string.list_view_empty)
                textSize = 30f
                gravity = Gravity.CENTER
            }.lparams {
                width = matchParent
                height = matchParent
            }
        }
    }
    // 3
    listView?.emptyView = emptyTextView
    
    return view
  1. 在activity里的布局可以直接寫vertical{},但是在fragment里不可以這樣。直接寫vertical{}就已經把這個layout添加到父view上了,這fragment里是不行的。在fragment里需要創建一個單獨的view,並返回。用with語句來創建這樣一個單獨的view。
  2. 在vertial layout里添加了一個textview。
  3. 上面一步創建的textview作為list view沒有數據的時候顯示的empty view來使用。

第二個tab,DetailFragment

日記的內容包括,日記title,日記本身的內容還有日記的日期。

所以布局上就包括日記的title、內容輸入用的EditText以及為了說明用的text view,還有edit text里的hint。最后還有一個選擇
日期的控件。

    return with(ctx) {
        verticalLayout {
            padding = dip(10)
            backgroundColor = Color.WHITE
            textView("TITLE") {

            }.lparams(width = matchParent)

            titleEditText = editText {
                hint = currentDateString()
                lines = 1
            }.lparams(width = matchParent) {
                topMargin = dip(5)
            }

            textView("CONTENT") {

            }.lparams(width = matchParent) {
                topMargin = dip(15)
            }

            contentEditText = editText {
                hint = "what's going on..."
                setHorizontallyScrolling(false)
            }.lparams(width = matchParent) {
                //                    height = matchParent
                topMargin = dip(5)
            }

            button(R.string.button_select_time) {
                gravity = Gravity.CENTER
                onClick {
                    val fm = activity.supportFragmentManager
                    var datePicker = DatePickerFragment.newInstance(diaryModel?.date)
                    datePicker.setTargetFragment(this@DetailFragment, DetailFragment.REQUEST_DATE)
                    datePicker.show(fm, "date")
                }
            }
            // *
            button(R.string.button_detail_ok) {
                onClick {
                    v ->
                    println("ok button clicked")
                    try {
                        var model = diaryModel!!
                        model.title = titleEditText?.text.toString()
                        model.content = contentEditText?.text.toString()
                        AnkoApplication.diaryDataSource.add(model)

                        modelChangeListener?.modelChanged()

                        toast(R.string.model_saved_ok)
                    } catch(e: Exception) {
                        Log.d("##DetailFragment", "error: ${e.toString()}")
                        toast(R.string.model_save_error)
                    }
                }
            }.lparams {
                topMargin = dip(10)
                width = matchParent
            }
        }.style {
            view ->
            when (view) {
                is Button -> {
                    view.gravity = Gravity.CENTER
                }
                is TextView -> {
                    view.gravity = Gravity.LEFT
                    view.textSize = 20f
                    view.textColor = Color.DKGRAY
                }
            }
        }
    }

需要注意打星號的地方。按鈕在點擊之后會彈出一個dialog fragment來顯示日期view。用戶可以在這個日期view里選擇相應的日期。但是,如何從日期dialog fragment傳遞選擇的日期給DetailFragment呢?這里就涉及到兩個fragment之間傳遞數據的問題。

選擇日期的dialog fragment是DatePickerFragment

    var pickerView = DatePicker(activity)
    pickerView.calendarViewShown = false
    pickerView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT)
    pickerView.init(year, month, day) {
        view, year, month, day ->
        mDate = GregorianCalendar(year, month, day).time

        arguments.putSerializable(EXTRA_DATE, mDate)
    }

    return AlertDialog.Builder(activity)
            .setView(pickerView)
            .setTitle(R.string.date_picker_title)
            .setPositiveButton(R.string.picker_button_ok) { dialog, which ->
                toast("hello world!")
                sendResult(Activity.RESULT_OK)
            }.create()

首先DatePickerFragment要繼承DialogFragment之后override方法onCreateDialog(savedInstanceState: Bundle)。在這個方法里使用上面代碼創建一個包含日期選擇器的dialog。

在選擇日期的時候,會觸發DatePickerOnDateChangedListener接口的onDateChanged方法。我們在這個方法里記錄選擇好的日期數據,在dialog的positive按鈕點擊之后把這個數據發送給DetailFragment

那么怎么發送呢?使用target fargment方法。在detail fragment彈出dialog fragment的時候,把detail fragment設置為target fragment。

button(R.string.button_select_time) {
    gravity = Gravity.CENTER
    onClick {
        val fm = activity.supportFragmentManager
        var datePicker = DatePickerFragment.newInstance(diaryModel?.date)
        // *
        datePicker.setTargetFragment(this@DetailFragment, DetailFragment.REQUEST_DATE)
        datePicker.show(fm, "date")
    }
}

在標星下面的一行代碼中。datePicker.setTargetFragment(this@DetailFragment,DetailFragment.REQUEST_DATE)DetailFragment設定為target fragment,並且指定REQUEST_DATE這code,為以后取出數據使用。

    companion object Factory {
        val REQUEST_DATE = 0`
    }

在positive按鈕點擊之后執行方法sendResult回傳數據

    private fun sendResult(resultCode: Int) {
        if (targetFragment == null)
            return

        var i = Intent()
        i.putExtra(EXTRA_DATE, mDate)
        // *
        targetFragment.onActivityResult(targetRequestCode, resultCode, i)
    }

調用targetFragmentonActivityResult()方法來回傳日期數據。

DetailFragment中通過override方法onActivityResult()來接收數據。

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (resultCode != Activity.RESULT_OK) {
            return
        }

        if (requestCode != REQUEST_DATE) {
            return
        }

        var date = data?.getSerializableExtra(DatePickerFragment.EXTRA_DATE) as Date
        diaryModel?.date = date
    }

日期數據傳輸這部分到這里結束。

全文也可以在這里畫上一個句點了。以上還有很多關於Anko沒有使用的地方。Anko也是可以實現代碼界面分離的。繼承AnkoComponent可以寫出獨立的布局文件,並且可以用anko preview插件來預覽界面效果。就拿setting這個tab的fragment來舉例:
首先定義一個獨立的布局文件:

class SettingsUI<T> : AnkoComponent<T> {
    override fun createView(ui: AnkoContext<T>) = with(ui) {
        verticalLayout {
            backgroundColor = ContextCompat.getColor(ctx, R.color.SnowWhite)
            textView { text = resources.getString(R.string.settings_title) }

            button("activity with the same `AnkoComponent`") {
                id = ID_BUTTON
            }
        }
    }

    companion object Factory {
        public val ID_BUTTON = 101
    }
}

把這個布局文件用在DiarySettingsFragment上:

    override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?,
        val view = SettingsUI<DiarySettingsFragment>().createView(AnkoContext.create(ctx, DiarySettingsFragment()))

        return view
    }

然后這個布局還可以用在我們剛剛創建的TempActivity上:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        SettingsUI<TempActivity>().setContentView(this)

        val button = find<Button>(SettingsUI.ID_BUTTON)
        button.text = "you are in `TempActivity`, CLICK!"

        button.onClick {
            toast("${TempActivity::class.java.simpleName}")
        }
    }

Activity上使用就簡單很多了,只需要這么一句SettingsUI<TempActivity>().setContentView(this)

代碼在這里。除了布局Anko還有其他的一些語法糖糖也很是不錯,不過這里就不多說了。有更多想了解的,請移步官網


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM