Magic Studio

专心做有逼格的APP!

使用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还有其他的一些语法糖糖也很是不错,不过这里就不多说了。有更多想了解的,请移步官网

posted on 2016-03-20 23:52  Mr 布鲁斯  阅读(10018)  评论(2编辑  收藏  举报

导航