Android自定义控件 | 时隔一年,用新知识重构一个老库
一年前,用 Java 写了一个高可扩展选择按钮库。单个控件实现单选、多选、菜单选,且选择模式可动态扩展。
一年后,一个新的需求要用到这个库,项目代码已经全 Kotlin 化,强硬地插入一些 Java 代码显得格格不入,Java 冗余的语法也降低了代码的可读性,于是决定用 Kotlin 重构一番,在重构的时候也增加了一些新的功能。这一篇分享下重构的过程。
选择按钮的可扩展性主要体现在 4 个方面:
- 选项按钮布局可扩展
- 选项按钮样式可扩展
- 选中样式可扩展
- 选择模式可扩展
扩展布局
原生的单选按钮通过
RadioButton+
RadioGroup实现,他们在布局上必须是父子关系,而
RadioGroup继承自
LinearLayout,遂单选按钮只能是横向或纵向铺开,这限制的单选按钮布局的多样性,比如下面这种三角布局就难以用原生控件实现:
为了突破这个限制,单选按钮不再隶属于一个父控件,它们各自独立,可以在布局文件中任意排列,图中 Activity 的布局文件如下(伪码):
[code]<androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Selector age" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <test.taylor.AgeSelector android:id="@+id/selector_teenager" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/title" app:layout_constraintStart_toStartOf="parent"/> <test.taylor.AgeSelector android:id="@+id/selector_man" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintEnd_toStartOf="@id/selector_old_man" app:layout_constraintTop_toBottomOf="@id/selector_teenager" app:layout_constraintStart_toStartOf="parent"/> <test.taylor.AgeSelector android:id="@+id/selector_old_man" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/selector_teenager" app:layout_constraintStart_toEndOf="@id/selector_man"/> </androidx.constraintlayout.widget.ConstraintLayout>
AgeSelector表示一个具体的按钮,本例中它是一个“上面是图片,下面是文字”的单选按钮。它继承自抽象的
Selector。
扩展样式
从业务上讲,
Selector长什么样是一个频繁的变化点,遂把“构建按钮样式”这个行为设计成
Selector的抽象函数
onCreateView(),供子类重写以实现扩展。
[code]public abstract class Selector extends FrameLayout{ public Selector(Context context) { super(context); initView(context, null); } private void initView(Context context, AttributeSet attrs) { // 初始化按钮算法框架 View view = onCreateView(); LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); this.addView(view, params); } // 如何构建按钮视图,延迟到子类实现 protected abstract View onCreateView(); }
Selector继承自
FrameLayout,实例化时会构建按钮视图,并把该视图作为孩子添加到自己的布局中。子类通过重写
onCreateView()扩展按钮样式:
[code]public class AgeSelector extends Selector { @Override protected View onCreateView() { View view = LayoutInflater.from(this.getContext()).inflate(R.layout.age_selector, null); return view; } }
AgeSelector的样式被定义在 xml 中。
按钮被选中之后的样式,也是一个业务上的变化点,用同样的思路可以将
Selector这样设计:
[code]// 抽象按钮实现点击事件 public abstract class Selector extends FrameLayout implements View.OnClickListener { public Selector(Context context) { super(context); initView(context, null); } private void initView(Context context, AttributeSet attrs) { View view = onCreateView(); LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); this.addView(view, params); // 设置点击事件 this.setOnClickListener(this); } @Override public void onClick(View v) { // 原有选中状态 boolean isSelect = this.isSelected(); // 反转选中状态 this.setSelected(!isSelect); // 展示选中状态切换效果 onSwitchSelected(!isSelect); return !isSelect; } // 按钮选中状态变化时的效果延迟到子类实现 protected abstract void onSwitchSelected(boolean isSelect); }
将选中按钮状态变化的效果抽象成一个算法,延迟到子类实现:
[code]public class AgeSelector extends Selector { // 单选按钮选中背景 private ImageView ivSelector; private ValueAnimator valueAnimator; @Override protected View onCreateView() { View view = LayoutInflater.from(this.getContext()).inflate(R.layout.selector, null); ivSelector = view.findViewById(R.id.iv_selector); return view; } @Override protected void onSwitchSelected(boolean isSelect) { if (isSelect) { playSelectedAnimation(); } else { playUnselectedAnimation(); } } // 播放取消选中动画 private void playUnselectedAnimation() { if (ivSelector == null) { return; } if (valueAnimator != null) { valueAnimator.reverse(); } } // 播放选中动画 private void playSelectedAnimation() { if (ivSelector == null) { return; } valueAnimator = ValueAnimator.ofInt(0, 255); valueAnimator.setDuration(800); valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { ivSelector.setAlpha((int) animation.getAnimatedValue()); } }); valueAnimator.start(); } }
AgeSelector
在选中状态变化时定义了一个背景色渐变动画。
函数类型变量代替继承
在抽象按钮控件中,“按钮样式”和“按钮选中状态变换”被抽象成算法,算法的实现推迟到子类,用这样的方式,扩展按钮的样式和行为。
继承的一个后果就是类数量的膨胀,有没有什么办法不用继承就能扩展按钮样式和行为?
可以把构建按钮样式的成员方法
onCreateView()设计成一个
View类型的成员变量,通过设值函数就可以改变其值。但按钮选中状态变换是一种行为,在 Java 中行为的表达方式只有方法,所以只能通过继承来改变行为。
Kotlin 中有一种类型叫
函数类型,运用这种类型,可以将行为保存在变量中:
[code]class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) { // 选中状态变换时的行为,它是一个lambda var onSelectChange: ((Selector, Boolean) -> Unit)? = null // 按钮是否被选中 var isSelecting: Boolean = false // 按钮样式 var contentView: View? = null set(value) { field = value value?.let { // 当按钮样式被赋值时,将其添加到 Selector,作为子视图 addView(it, LayoutParams(MATCH_PARENT, MATCH_PARENT)) } } // 变更按钮选中状态 fun setSelect(select: Boolean) { showSelectEffect(select) } // 展示选中状态变换效果 fun showSelectEffect(select: Boolean) { // 如果选中状态发生变化,则执行选中状态变换行为 if (isSelecting != select) { onSelectChange?.invoke(this, select) } isSelecting = select } }
选中样式和行为都被抽象为一个成员变量,只需赋值就可以动态扩展,不再需要继承:
[code]// 构建按钮实例 val selector = Selector { layout_width = 90 layout_height = 50 contentView = ageSelectorView onSelectChange = onAgeSelectStateChange } // 构建按钮样式 private val ageSelectorView: ConstraintLayout get() = ConstraintLayout { layout_width = match_parent layout_height = match_parent // 按钮选中背景 ImageView { layout_id = "ivSelector" layout_width = 0 layout_height = 30 top_toTopOf = "ivContent" bottom_toBottomOf = "ivContent" start_toStartOf = "ivContent" end_toEndOf = "ivContent" background_res = R.drawable.age_selctor_shape alpha = 0f } // 按钮图片 ImageView { layout_id = "ivContent" layout_width = match_parent layout_height = 30 center_horizontal = true src = R.drawable.man top_toTopOf = "ivSelector" } // 按钮文字 TextView { layout_id = "tvTitle" layout_width = match_parent layout_height = wrap_content bottom_toBottomOf = parent_id text = "man" gravity = gravity_center_horizontal } } // 按钮选中行为 private val onAgeSelectStateChange = { selector: Selector, select: Boolean -> // 根据选中状态变换按钮选中背景 selector.find<ImageView>("ivSelector")?.alpha = if (select) 1f else 0f }
在构建
Selector实例的同时,指定了它的样式和选中变换效果(其中运用到 DSL 简化构建代码)
扩展选中模式
单个Selector
已经可以很好的工作,但要让多个
Selector形成一种单选或多选的模式,还需要一个管理器来同步它们之间的选中状态,Java 版本的管理器如下:
[code]public class SelectorGroup { // 选中模式 public interface ChoiceAction { void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener); } // 选中状态监听器 public interface StateListener { void onStateChange(String groupTag, String tag, boolean isSelected); } // 选中模式实例 private ChoiceAction choiceMode; // 选中状态监听器实例 private StateListener onStateChangeListener; // 用于上一次选中的按钮的 Map private HashMap<String, Selector> selectorMap = new HashMap<>(); // 注入选中模式 public void setChoiceMode(ChoiceAction choiceMode) { this.choiceMode = choiceMode; } // 设置选中状态监听器 public void setStateListener(StateListener onStateChangeListener) { this.onStateChangeListener = onStateChangeListener; } // 获取之前选中的按钮 public Selector getPreSelector(String groupTag) { return selectorMap.get(groupTag); } // 变更指定按钮的选中状态 public void setSelected(boolean selected, Selector selector) { if (selector == null) { return; } // 记忆选中的按钮 if (selected) { selectorMap.put(selector.getGroupTag(), selector); } // 触发按钮选中样式变更 selector.setSelected(selected); if (onStateChangeListener != null) { onStateChangeListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), selected); } } // 取消之前选中的按钮 private void cancelPreSelector(Selector selector) { // 每个按钮有一个组标识,用于标识它属于哪个组 String groupTag = selector.getGroupTag(); // 获取该组中之前选中的按钮并将其取消选中 Selector preSelector = getPreSelector(groupTag); if (preSelector != null) { preSelector.setSelected(false); } } // 当按钮被点击时,会将点击事件通过该函数传递给 SelectorGroup void onSelectorClick(Selector selector) { // 将点击事件委托给选择模式来处理 if (choiceMode != null) { choiceMode.onChoose(selector, this, onStateChangeListener); } // 将选中的按钮记录在 Map 中 selectorMap.put(selector.getGroupTag(), selector); } // 预定的单选模式 public class SingleAction implements ChoiceAction { @Override public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) { cancelPreSelector(selector); setSelected(true, selector); } } // 预定的多选模式 public class MultipleAction implements ChoiceAction { @Override public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) { boolean isSelected = selector.isSelected(); setSelected(!isSelected, selector); } } }
SelectorGroup将选中模式抽象成接口
ChoiceAction,以便通过
setChoiceMode()动态地扩展。
SelectorGroup还预定了两种选中模式:单选和多选。
- 单选可以理解为:点击按钮时,选中当前的并取消选中之前的。
- 多选可以理解为:点击按钮时无条件地反转当前选中状态。
Selector会持有
SelectorGroup实例,以便将按钮点击事件传递给它统一管理:
[code]public abstract class Selector extends FrameLayout implements View.OnClickListener { // 按钮组标签 private String groupTag; // 按钮管理器 private SelectorGroup selectorGroup; // 设置组标签和管理器 public Selector setGroup(String groupTag, SelectorGroup selectorGroup) { this.selectorGroup = selectorGroup; this.groupTag = groupTag; return this; } @Override public void onClick(View v) { // 将点击事件传递给管理器 if (selectorGroup != null) { selectorGroup.onSelectorClick(this); } } }
然后就可以像这样实现单选:
[code]SelectorGroup singleGroup = new SelectorGroup(); singleGroup.setChoiceMode(SelectorGroup.SingleAction); selector1.setGroup("single", singleGroup); selector2.setGroup("single", singleGroup); selector3.setGroup("single", singleGroup);
也可以像这样实现菜单选:
[code]SelectorGroup orderGroup = new SelectorGroup(); orderGroup.setStateListener(new OrderChoiceListener()); orderGroup.setChoiceMode(new OderChoiceMode()); // 前菜组 selector1_1.setGroup("starters", orderGroup); selector1_2.setGroup("starters", orderGroup); // 主食组 selector2_1.setGroup("main", orderGroup); selector2_2.setGroup("main", orderGroup); // 汤组 selector3_1.setGroup("soup", orderGroup); selector3_2.setGroup("soup", orderGroup); // 菜单选:组内单选,跨组多选 private class OderChoiceMode implements SelectorGroup.ChoiceAction { @Override public void onChoose(Selector selector, SelectorGroup selectorGroup, SelectorGroup.StateListener stateListener) { cancelPreSelector(selector, selectorGroup); selector.setSelected(true); if (stateListener != null) { stateListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), true); } } // 取消之前选中的同组按钮 private void cancelPreSelector(Selector selector, SelectorGroup selectorGroup) { Selector preSelector = selectorGroup.getPreSelector(selector.getGroupTag()); if (preSelector != null) { preSelector.setSelected(false); } } }
将 Java 中的接口改成
lambda,存储在函数类型的变量中,这样可省去注入函数,Kotlin 版本的
SelectorGroup如下:
[code]class SelectorGroup { companion object { // 单选模式的静态实现 var MODE_SINGLE = { selectorGroup: SelectorGroup, selector: Selector -> selectorGroup.run { // 查找同组中之前选中的,取消其选中状态 findLast(selector.groupTag)?.let { setSelected(it, false) } // 选中当前按钮 setSelected(selector, true) } } // 多选模式的静态实现 var MODE_MULTIPLE = { selectorGroup: SelectorGroup, selector: Selector -> selectorGroup.setSelected(selector, !selector.isSelecting) } } // 所有当前选中按钮的有序集合(有些场景需要记忆按钮选中的顺序) private var selectorMap = LinkedHashMap<String, MutableSet<Selector>>() // 当前的选中模式(函数类型) var choiceMode: ((SelectorGroup, Selector) -> Unit)? = null // 选中状态变更监听器, 将所有选中按钮回调出去(函数类型) var selectChangeListener: ((List<Selector>/*selected set*/) -> Unit)? = null // Selector 将点击事件通过这个方法传递给 SelectorGroup fun onSelectorClick(selector: Selector) { // 将点击事件委托给选中模式 choiceMode?.invoke(this, selector) } // 查找指定组的所有选中按钮 fun find(groupTag: String) = selectorMap[groupTag] // 根据组标签查找该组中上一次被选中的按钮 fun findLast(groupTag: String) = find(groupTag)?.takeUnless { it.isNullOrEmpty() }?.last() // 变更指定按钮的选中状态 fun setSelected(selector: Selector, select: Boolean) { // 或新建,或删除,或追加选中的按钮到Map中 if (select) { selectorMap[selector.groupTag]?.also { it.add(selector) } ?: also { selectorMap[selector.groupTag] = mutableSetOf(selector) } } else { selectorMap[selector.groupTag]?.also { it.remove(selector) } } // 展示选中效果 selector.showSelectEffect(select) // 触发选中状态监听器 if (select) { selectChangeListener?.invoke(selectorMap.flatMap { it.value }) } } // 释放持有的选中控件 fun clear() { selectorMap.clear() } }
然后就可以像这样使用
SelectorGroup:
[code]// 构建管理器 val singleGroup = SelectorGroup().apply { choiceMode = SelectorGroup.MODE_SINGLE selectChangeListener = { selectors: List<Selector>-> // 在这里可以拿到选中的所有按钮 } } // 构建单选按钮1 Selector { tag = "old-man" group = singleGroup groupTag = "age" layout_width = 90 layout_height = 50 contentView = ageSelectorView } // 构建单选按钮2 Selector { tag = "young-man" group = singleGroup groupTag = "age" layout_width = 90 layout_height = 50 contentView = ageSelectorView }
构建的两个按钮拥有相同的
groupTag和
SelectorGroup,所以他们属于同一组并且是单选模式。
动态绑定数据
项目中一个按钮通常对应于一个“数据”,比如下图这种场景:
图中的分组数据和按钮数据都由服务器返回。点击创建组队时,希望在
selectChangeListener中拿到每个选项的 ID。那如何为
Selector绑定数据?
当然可以通过继承,在
Selector子类中添加一个具体的业务数据类型来实现。但有没有更通用的方案?
ViewModel中设计了一种为其动态扩展属性的方法,将它应用在
Selector中。
[code]class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) { // 存放业务数据的容器 private var tags = HashMap<Any?, Closeable?>() // 获取业务数据(重载取值运算符) operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T // 添加业务数据(重载设值运算符) operator fun <T : Closeable> set(key: Key<T>, closeable: Closeable) { tags[key] = closeable } // 清除所有业务数据 private fun clear() { group?.clear() tags.forEach { entry -> closeWithException(entry.value) } } // 当控件与窗口脱钩时,清理业务数据 override fun onDetachedFromWindow() { super.onDetachedFromWindow() clear() } // 清除单个业务数据 private fun closeWithException(closable: Closeable?) { try { closable?.close() } catch (e: Exception) { } } // 业务数据的键 interface Key<E : Closeable> }
为
Selector新增一个
Map类型的成员用于存放业务数据,业务数据被声明为
Closeable的子类型,目的是将各式各样清理资源的行为抽象为
close()方法,
Selector重写了
onDetachedFromWindow()且会遍历每个业务数据并调用它们的
close(),即当它生命周期结束时,释放业务数据资源。
Selector也重载了设值和取值这两个运算符,以简化业访问业务数据的代码:
[code]// 游戏属性实体类 data class GameAttr( var name: String, var id: String ): Closeable { override fun close() { name = null id = null } } // 构建游戏属性实例 val attr = GameAttr("黄金", "id-298") // 和游戏属性实体配对的键 val key = object : Selector.Key<GameAttr> {} // 构建选项组 val gameSelectorGroup by lazy { SelectorGroup().apply { // 选择模式(省略) choiceMode = { selectorGroup, selector -> ... } // 选中回调 selectChangeListener = { selecteds -> // 遍历所有选中的选项 selecteds.forEach { s -> // 访问与每个选项绑定的游戏属性(用到取值运算符) Log.v("test","${s[key].name} is selected") } } } } // 构建选项 Selector { tag = attr.name groupTag = "匹配段位" group = gameSelectorGroup layout_width = 70 layout_height = 32 // 绑定游戏属性(用到设值运算符) this[key] = attr }
因为重载了运算符,所以绑定和获取游戏属性的代码都更加简短。
用泛型就一定要强转?
绑定给 Selector
的数据被设计为泛型,业务层只有强转成具体类型才能使用,有什么办法可以不要在业务层强转?
CoroutineContext的键就携带了类型信息:
[code]public interface CoroutineContext { public interface Key<E : Element> public operator fun <E : Element> get(key: Key<E>): E? }
而且每一个
CoroutineContext的具体子类型都对应一个静态的键实例:
[code]public interface Job : CoroutineContext.Element { public companion object Key : CoroutineContext.Key<Job> {} }
这样,不需要强转就能获得具体子类型:
[code]coroutineContext[Job]//返回值为 Job 而不是 CoroutineContext
模仿
CoroutineContext,业务
Selector的键设计了一个带泛型的接口:
[code]interface Key<E : Closeable>
在为
Selector绑定数据时需要先构建“键实例”:
[code]val key = object : Selector.Key<GameAttr> {}
传入的键带有类型信息,可以在取值方法中提前完成强转再返回给业务层使用:
[code]// 值的具体类型被参数 key 指定,强转之后再返回给业务层 operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T
借助于 DSL 根据数据动态地构建选择按钮就变得很轻松,上一幅 Gif 展示的界面代码如下:
[code]// 游戏属性集合实体类 data class GameAttrs( var title: String?,// 选项组标题 var attrs: List<GameAttrName>? // 选项组内容 ) // 简化的单个游戏属性实体类(它会被绑定到Selector) data class GameAttrName( var name: String? ) : Closeable { override fun close() { name = null } }
这是两个 Demo 中用到的数据实体类,真实项目中他们应该是服务器返回的,简单起见,本地模拟一些数据:
[code]val gameAttrs = listOf( GameAttrs( "大区", listOf( GameAttrName("微信"), GameAttrName("QQ") ) ), GameAttrs( "模式", listOf( GameAttrName("排位赛"), GameAttrName("普通模式"), GameAttrName("娱乐模式"), GameAttrName("游戏交流") ) ), GameAttrs( "匹配段位", listOf( GameAttrName("青铜白银"), GameAttrName("黄金"), GameAttrName("铂金"), GameAttrName("钻石"), GameAttrName("星耀"), GameAttrName("王者") ) ), GameAttrs( "组队人数", listOf( GameAttrName("三排"), GameAttrName("五排") ) ) )
最后用 DSL 动态构建选择按钮:
[code]// 纵向布局 LinearLayout { layout_width = match_parent layout_height = 573 orientation = vertical // 遍历游戏集合,动态添加选项组 gameAttrs?.forEach { gameAttr -> // 添加选项组标题 TextView { layout_width = wrap_content layout_height = wrap_content textSize = 14f textColor = "#ff3f4658" textStyle = bold text = gameAttr.title } // 自动换行容器控件 LineFeedLayout { layout_width = match_parent layout_height = wrap_content // 遍历游戏属性,动态添加选项按钮 gameAttr.attrs?.forEachIndexed { index, attr -> Selector { layout_id = attr.name tag = attr.name groupTag = gameAttr.title // 为按钮设置控制器 group = gameSelectorGroup // 为按钮指定视图 contentView = gameAttrView // 为按钮设置选中效果变换器 onSelectChange = onGameAttrChange layout_width = 70 layout_height = 32 // 为按钮绑定数据并更新视图 bind = Binder(attr) { _, _ -> this[gameAttrKey] = attr find<TextView>("tvGameAttrName")?.text = attr.name } } } } } }
其中的按钮视图、按钮控制器、按钮效果变换器定义如下:
[code]// 与游戏属性对应的键 val gameAttrKey = object : Selector.Key<GameAttrName> {} // 构建游戏属性视图 val gameAttrView: TextView? get() = TextView { layout_id = "tvGameAttrName" layout_width = 70 layout_height = 32 textSize = 12f textColor = "#ff3f4658" background_res = R.drawable.bg_game_attr gravity = gravity_center padding_top = 7 padding_bottom = 7 } // 按钮选中状态变化时,变更背景色及按钮字体颜色 private val onGameAttrChange = { selector: Selector, select: Boolean -> selector.find<TextView>("tvGameAttrName")?.apply { background_res = if (select) R.drawable.bg_game_attr_select else R.drawable.bg_game_attr textColor = if (select) "#FFFFFF" else "#3F4658" } Unit } // 构建按钮控制器 private val gameSelectorGroup by lazy { SelectorGroup().apply { choiceMode = { selectorGroup, selector -> // 设置除“匹配段位选项组”之外的其他组为单选 if (selector.groupTag != "匹配段位") { selectorGroup.apply { findLast(selector.groupTag)?.let { setSelected(it, false) } } selectorGroup.setSelected(selector, true) } // 设置“匹配段位选项组”为多选 else { selectorGroup.setSelected(selector, !selector.isSelecting) } } // 选中按钮发生变化时,都会在这里回调 selectChangeListener = { selecteds -> selecteds.forEach { s-> Log.v("test","${s[gameAttrKey]?.name} is selected") } } } }
- android反射组件 (一个)java 它们的定义annotation基础知识
- 让你的app提升一个档次-Android酷炫自定义控件
- Android 自定义控件学习之一 基础知识
- 翻译《有关编程、重构及其他的终极问题?》——18.你在一个语言上积累的经验和知识不总是适用于另外一门语言
- Android开发自定义控件实现一个饼状图
- Android自定义控件系列:详解onMeasure()方法中如何测量一个控件尺寸(一)
- Android自定义控件必备知识——自定义属性
- Android 自定义控件-Canvas和Paint绘图详解-手把手带你绘制一个时钟.
- Android日常知识收集之通过一个按钮来控制密码是明文还是暗文显示
- 为什么Android应用用Java开发,为什么Android大型游戏要用数据包?这里根据我的知识做一个总结
- Android自定义控件继承ViewGrop实现一个GridView的显示效果
- 【破解】考试系统-融e学-一个专注于重构知识,培养复合型人才的平台。
- 开发一个完整android app 需要掌握哪些知识?
- [转]android自定义控件 一个带图片和文字的按钮
- Android开发-直播视讯(3)-创建一个Ubuntu虚拟机并实现VMtools文件夹共享-基础知识
- Android自定义控件系列七:详解onMeasure()方法中如何测量一个控件尺寸(一)
- Android自定义控件前导基础知识学习(一)——Canvas
- Android自定义控件:做一个拼图游戏
- android继承一个布局文件实现自定义控件
- Android自定义控件前导基础知识学习(一)——Canvas