UI开发的点滴

UI开发

基础知识:

  1. dp,这是一种屏幕密度无关的尺寸单位,可以保证在不同分辨率的手机上显示效果尽可能地一致。
  2. 文字大小要使用sp作为单位,这样当用户在系统中修改了文字显示尺寸时,应用程序中的文字大小也会跟着变化;
  3. Android控件的可见属性,可以通过android:visibility进行指定,可选值有3种:visible、invisible和gone。
    1. visible表示控件是可见的,这个值是默认值,不指定android:visibility时,控件都是可见的;
    2. invisible表示控件不可见,但是它仍然占据着原来的位置和大小,可以理解成控件变成透明状态了;
    3. gone则表示控件不仅不可见,而且不再占用任何屏幕空间。

控件

基础知识:

  1. android:gravity来指定文字的对齐方式,可选值有top、bottom、start、end、center等,可以用“|”来同时指定多个值;

TextView

无赘述。

Button

Android系统默认会将按钮上的英文字母全部转换成大写,可能是认为按钮上的内容都比较重要吧。如果这不是你想要的效果,可以在XML中添加android:textAllCaps=”false” 这个属性。

注册监听器的两种方式:

  1. Java函数式API;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class MainActivity : AppCompatActivity() { 
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    button.setOnClickListener {
    // 在此处添加逻辑
    }
    }
    }
  2. 使用实现接口的方式来进行注册。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class MainActivity : AppCompatActivity(), View.OnClickListener { 

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

    override fun onClick(v: View?) {
    when (v?.id) {
    R.id.button -> {
    // 在此处添加逻辑
    }
    }
    }
    }

    这里我们让MainActivity实现了View.OnClickListener接口,并重写了onClick()方法,然后在调用button的setOnClickListener()方法时将MainActivity的实例传了进去。这样每当点击按钮时,就会执行onClick()方法中的代码了。

EditText

  1. 可以使用android:hint属性指定一段提示性的文本;
  2. 可以使用android:maxLines指定最大行数。

ImageView

现在最主流的手机屏幕分辨率大多是xxhdpi的,需要在res目录下新建一个drawable-xxhdpi目录。

ProgressBar

  1. android:max属性给进度条设置一个最大值;
  2. style属性可以指定成进度条样式。

AlertDialog

AlertDialog可以在当前界面弹出一个对话框。调用setPositiveButton()方法为对话框设置确定按钮的点击事件,调用setNegativeButton()方法设置取消按钮的点击事件。

布局

LinearLayout

LinearLayout又称作线性布局。

  1. 如果LinearLayout的排列方向是horizontal,内部的控件就绝对不能将宽度指定为match_parent;如果LinearLayout的排列方向是vertical,内部的控件就不能将高度指定为match_parent。
  2. 当LinearLayout的排列方向是horizontal时,只有垂直方向上的对齐方式才会生效。当LinearLayout的排列方向是vertical时,只有水平方向上的对齐方式才会生效。

android:layout_weight这个属性允许我们使用比例的方式来指定控件的大小,它在手机屏幕的适配性方面可以起到非常重要的作用,本质是按照数值和进行归一化分配比例。如果是水平布局,使用android:layout_weight后,控件的宽度就不应该再由android:layout_width来决定了,直接赋值0dp即可;垂直布局同理。

RelativeLayout

RelativeLayout又称作相对布局。每个控件都是相对于父布局进行定位的,也可以相对于控件进行定位。

一个有两组属性,一组按照边缘定位(如:android:layout_alignLeft),一组按照中心(如: android:layout_toLeftOf)定位。

当一个控件去引用另一个控件的id时,该控件一定要定义在引用控件的后面,不然会出现找不到id的情况。

FrameLayout

FrameLayout又称作帧布局,简单且应用场景少。以使用layout_gravity属性来指定控件在布局中的对齐方式。

自定义控件

所用的所有控件都是直接或间接继承自View的,所用的所有布局都是直接或间接继承自ViewGroup的。View是Android中最基本的一种UI组件,它可以在屏幕上绘制一块矩形区域,并能响应这块区域的各种事件,因此,各种控件其实就是在View的基础上又添加了各自特有的功能。而ViewGroup则是一种特殊的View,它可以包含很多子View和子ViewGroup,是一个用于放置控件和布局的容器。

引入布局

如果在每个Activity的布局中都编写一遍同样的布局代码,明显就会导致代码的大量重复。这时我们就可以使用引入布局的方式来解决这个问题。

只需要通过一行include语句引入布局。

1
2
3
4
5
6
7
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 
android:layout_width="match_parent"
android:layout_height="match_parent" >

<include layout="@layout/title" />

</LinearLayout>

创建自定义控件

这里我们在TitleLayout的主构造函数中声明了Context(本质就是Activity的实例)和AttributeSet这两个参数,在布局中引入TitleLayout控件时就会调用这个构造函数。通过LayoutInflater的from()方法可以构建出一个LayoutInflater对象,然后调用inflate()方法就可以动态加载一个布局文件。

1
2
3
4
5
6
7
class TitleLayout(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) { 

init {
LayoutInflater.from(context).inflate(R.layout.title, this)
}

}

添加自定义控件和添加普通控件的方式基本是一样的,只不过在添加自定义控件的时候,我们需要指明控件的完整类名,包名在这里是不可以省略的。

1
2
3
4
5
6
7
8
9
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 
android:layout_width="match_parent"
android:layout_height="match_parent" >

<com.example.uicustomviews.TitleLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" />

</LinearLayout>

注意,TitleLayout中接收的context参数实际上是一个Activity的实例,在返回按钮的点击事件里,我们要先将它转换成Activity类型,然后再调用finish()方法销毁当前的Activity。Kotlin中的类型强制转换使用的关键字是as

1
2
3
4
5
6
7
8
9
10
11
12
13
class TitleLayout(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) { 

init {
LayoutInflater.from(context).inflate(R.layout.title, this)
titleBack.setOnClickListener {
val activity = context as Activity
activity.finish()
}
titleEdit.setOnClickListener {
Toast.makeText(context, "You clicked Edit button", Toast.LENGTH_SHORT).show()
}
}
}

ListView

简单用法

数据是无法直接传递给ListView的,需要借助适配器来完成。Android中提供了很多适配器的实现类,如ArrayAdapter。它可以通过泛型来指定要适配的数据类型,然后在构造函数中把要适配的数据传入。在ArrayAdapter的构造函数中依次传入Activity的实例、ListView子项布局的id,以及数据源。

还需要调用ListView的setAdapter()方法,将构建好的适配器对象传递进去,这样ListView和数据之间的关联就建立完成。

定制界面

创建一个自定义的适配器,这个适配器继承自ArrayAdapter,并将泛型指定为数据类。定义一个主构造函数,用于将Activity的实例、ListView子项布局的id和数据源传递进来(类似标准ArrayAdapter)。另外又重写了getView()方法,这个方法在每个子项被滚动到屏幕内的时候会被调用。

接着在onCreate()方法中创建 了适配器对象,并将它传递给ListView,这样定制ListView界面的任务就完成了。

提升运行效率

  1. getView()方法中还有一个convertView参数,这个参数用于将之前加载好的布局进行缓存,以便之后进行重用,我们可以借助这个参数来进行性能优化,避免重复加载布局。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class FruitAdapter(activity: Activity, val resourceId: Int, data: List<Fruit>) : 
    ArrayAdapter<Fruit>(activity, resourceId, data) {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    val view: View
    if (convertView == null) {
    view = LayoutInflater.from(context).inflate(resourceId, parent, false)
    } else {
    view = convertView
    }
    val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
    val fruitName: TextView = view.findViewById(R.id.fruitName)
    val fruit = getItem(position) // 获取当前项的Fruit实例
    if (fruit != null) {
    fruitImage.setImageResource(fruit.imageId)
    fruitName.text = fruit.name
    }
    return view
    }
    }
  2. 借助ViewHolder来对性能进行优化,避免重复调用View的findViewById()方法来获取一次控件的实例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    class FruitAdapter(activity: Activity, val resourceId: Int, data: List<Fruit>) : 
    ArrayAdapter<Fruit>(activity, resourceId, data) {
    inner class ViewHolder(val fruitImage: ImageView, val fruitName: TextView)
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    val view: View
    val viewHolder: ViewHolder
    if (convertView == null) {
    view = LayoutInflater.from(context).inflate(resourceId, parent, false)
    val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
    val fruitName: TextView = view.findViewById(R.id.fruitName)
    viewHolder = ViewHolder(fruitImage, fruitName)
    view.tag = viewHolder
    } else {
    view = convertView
    viewHolder = view.tag as ViewHolder
    }

    val fruit = getItem(position) // 获取当前项的Fruit实例
    if (fruit != null) {
    viewHolder.fruitImage.setImageResource(fruit.imageId)
    viewHolder.fruitName.text = fruit.name
    }
    return view
    }

    }

    新增一个内部类ViewHolder,用于对ImageView和TextView的控件实例进行缓存。然后调用View的setTag()方法,将ViewHolder对象存储在View中。

点击事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MainActivity : AppCompatActivity() { 

private val fruitList = ArrayList<Fruit>()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initFruits() // 初始化水果数据
val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
listView.adapter = adapter
listView.setOnItemClickListener { _, _, position, _ ->
val fruit = fruitList[position]
Toast.makeText(this, fruit.name, Toast.LENGTH_SHORT).show()
}
}

...

}

使用setOnItemClickListener()方法为ListView注册了一个监听器,这是一个Java单抽象方法接口,故使用Java函数式API编程。Kotlin允许我们将没有用到的参数使用下划线来替代。

RecyclerView

基本用法

RecyclerView是一个增强版的 ListView。RecyclerView属于新增控件,考虑到兼容性,Google将RecyclerView控件定义在了AndroidX 当中,我们需要在项目的build.gradle中添加RecyclerView库的依赖。

1
2
3
4
5
6
7
8
9
10
11
dependencies { 
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.core:core-ktx:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}

为RecyclerView准备一个适配器,新建FruitAdapter类,让这个适配器继承自RecyclerView.Adapter,并将泛型指定为FruitAdapter.ViewHolder。其中,ViewHolder是我们在FruitAdapter中定义的一个内部类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class FruitAdapter(val fruitList: List<Fruit>) : 
RecyclerView.Adapter<FruitAdapter.ViewHolder>() {

inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
val fruitName: TextView = view.findViewById(R.id.fruitName)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fruit_item, parent, false)
return ViewHolder(view)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val fruit = fruitList[position]
holder.fruitImage.setImageResource(fruit.imageId)
holder.fruitName.text = fruit.name
}

override fun getItemCount() = fruitList.size

}

由于FruitAdapter是继承自RecyclerView.Adapter的,那么就必须重写onCreateViewHolder()、onBindViewHolder()和getItemCount()这3个方法。onCreateViewHolder()方法是用于创建ViewHolder实例;onBindViewHolder()方法用于对RecyclerView子项的数据进行赋值,会在每个子项被滚动到屏幕内的时候执行;getItemCount()方法就非常简单了,它用于告诉RecyclerView一共有多少子项,直接返回数据源的长度就可以了。

在onCreate()方法中先创建了一个LinearLayoutManager对象,并将它设置到RecyclerView当中。最后调用 RecyclerView的setAdapter()方法来完成适配器设置,这样RecyclerView和数据之间的关联就建立完成。

实现横向滚动

MainActivity中只加入了一行代码,调用LinearLayoutManager的setOrientation()方法设置布局的排列方向。默认是纵向排列的。

其他布局

ListView的布局排列是由自身去管理的,而RecyclerView则将这个工作交给了LayoutManager。LayoutManager制定了一套可扩展的布局排列接口,子类只要按照接口的规范来实现,就能定制出各种不同排列方式的布局了。

不赘述。

点击事件

RecyclerView并没有提供类似于setOnItemClickListener()这样的注册监听器方法。RecyclerView直接摒弃了子项点击事件的监听器,让所有的点击事件都由具体的View去注册,在onCreateViewHolder()方法中注册点击事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class FruitAdapter(val fruitList: List<Fruit>) : 
RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
...
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fruit_item, parent, false)
val viewHolder = ViewHolder(view)
viewHolder.itemView.setOnClickListener {
val position = viewHolder.adapterPosition
val fruit = fruitList[position]
Toast.makeText(parent.context, "you clicked view ${fruit.name}",
Toast.LENGTH_SHORT).show()
}
viewHolder.fruitImage.setOnClickListener {
val position = viewHolder.adapterPosition
val fruit = fruitList[position]
Toast.makeText(parent.context, "you clicked image ${fruit.name}",
Toast.LENGTH_SHORT).show()
}
return viewHolder
}
...
}

编写页面

9-Patch图片

它是一种被特殊处理过的png图片,能够指定哪些区域可以被拉伸、哪些区域不可以。

制作过程不赘述。

要将原来的.png图片删除,只保留制作好的.9.png图片。Android项目中不允许同一文件夹下有两张相同名称的图片(即使后缀名不同也不行)。

聊天界面

定义常量的关键字是const,注意只有在单例类、companion object或顶层方法中才可以使用const关键字。

根据不同的viewType创建不同的界面。要重写 getItemViewType()方法,并在这个方法中返回当前position对应的消息类型。

使用适配器的notifyItemInserted()方法,用于通知列表有新的数据插入,这样新增的一条消息才能够在RecyclerView中显示出来。或者调用适配器的notifyDataSetChanged()方法,它会将RecyclerView中所有可见的元素全部刷新,这样不管是新增、删除、还是修改元素,界面上都会显示最新的数据,但缺点是效率会相对差一些。

调用RecyclerView的scrollToPosition()方法将显示的数据定位到最后一行,以保证一定可以看得到最后发出的一条消息。最后调用EditText的setText()方法将输入的内容清空。

具体代码不赘述。

Kotlin课堂

主要讲解延迟初始化密封类

延迟初始化

延迟初始化使用的是lateinit关键字,它可以告诉Kotlin编译器,我会在晚些时候对这个变量进行初始化,这样就不用在一开始的时候将它赋值为null了,同时类型声明也不用添加?,也不再需要进行判空处理。

使用lateinit的风险:变量还没有初始化的情况下就直接使用它,那么程序就一定会崩溃,并且抛出一个UninitializedPropertyAccessException异常。

::adapter.isInitialized可用于判断adapter变量是否已经初始化,这是固定写法。

密封类

由于Kotlin的语法规定,when语句必须要以else结束,否则编译器会认为这里缺少条件分支,产生代码冗余;同时写else条件还有一个潜在的风险,如果我们现在新增了一个Unknown类并实现接口,用于表示未知的执行结果,但是忘记在方法中添加相应的条件分支,编译器在这种情况下是不会提醒我们的,而是会在运行的时候进入else条件里面,从而抛出异常并导致程序崩溃。

密封类的关键字是sealed class,密封类是一个可继承的类,因此在继承它的时候需要在后面加上一对括号。

当在when语句中传入一个密封类变量作为条件时,Kotlin编译器会自动检查该密封类有哪些子类,并强制要求你将每一个子类所对应的条件全部处理。

另外再多说一句,密封类及其所有子类只能定义在同一个文件的顶层位置,不能嵌套在其他类中,这是被密封类底层的实现机制所限制的。