版权声明
凡未经作者授权,任何媒体、网站及个人不得转载、复制、重制、改动、展示或使用局部或全部的内容或服务。如果已转载,请自行删除。同时,我们保留进一步追究相关行为主体的法律责任的权利。
© 2024 小酥肉不加辣,All rights reserved.

上一篇 Android 动态页面框架(一)从设计上讲述了动态页面的架构和核心逻辑,本篇将从实现角度以示例代码的方式描述关键部分的细节。
注册
在上文中已经提到,以在 AndroidManifest.xml 文件中使用 meta-data 注册 XML 配置文件的方式注册,其他应用可以扫描并获取 XML 配置文件的资源 ID,从而获取到配置文件内容,进而达到动态页面配置的目的。
这种注册的方式可以通过封装 SDK,提前配置的方式,减少其他 Client 应用的代码量,更好的集成到框架中。在示例工程的 sdk 模块的 AndroidManifest.xml 文件中,
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission android:name="com.wx.chameleon.PERMISSION" />
<uses-permission android:name="com.wx.chameleon.PERMISSION"
<application>
<receiver
android:name=".ChameleonReceiver"
android:enabled="true"
android:exported="true"
android:permission="com.wx.chameleon.PERMISSION">
<intent-filter>
<action android:name="com.wx.action.CHAMELEON" />
<data
android:host="chameleon"
android:scheme="wx" />
</intent-filter>
<meta-data
android:name="chameleon_page"
android:resource="@xml/chameleon_page" />
</receiver>
</application>
</manifest>
可以看到在 receiver 的定义中添加了meta-data 标签,这样集成 sdk 的 client 应用无需在它的 AndroidManifest.xml 文件中重复定义,只需要在 res/xml 文件夹下新增一个名为 chameleon_page.xml 的文件即可。
以示例工程的 sample 模块为例,AndroidManifest.xml 文件无需任何改动,res/xml/chameleon_page.xml 文件里编写动态页面的配置代码。
<?xml version="1.0" encoding="utf-8"?>
<!--<!DOCTYPE Chameleon SYSTEM "chameleon.dtd">-->
<Chameleon processor="com.wx.chameleon.sample.SampleProcessor">
<Page
title="Test Page"
category="Test"
key="key_test_page"
label="Test Page">
<Text
key="test_text"
label="Test Text" />
<Button
click="click_button"
key="key_test_button"
label="Test Button" />
<Toggle
click="click_toggle"
key="test_toggle"
label="Test Toggle" />
<Dropdown
click="click_dropdown"
key="test_dropdown"
label="Test Dropdown">
<item>option 1</item>
<item>option 2</item>
<item>option 3</item>
</Dropdown>
<Group label="Group title">
<Text
key="test_text"
label="Test Text" />
</Group>
<Page
title="Test Child Page"
category="Test"
key="key_test_child_page"
label="Test Child Page">
<Text
key="test_text"
label="Test Text" />
</Page>
</Page>
</Chameleon>
扫描
在示例工程的 core 模块的 Scanner.kt 文件中,核心逻辑是调用 PackageManager 的 queryBroadcastReceivers 方法扫描其他应用的 receiver 信息,并获取到注册阶段定义的 chameleon_page.xml 文件的 resource id。
private fun queryReceivers(): List<ResolveInfo> {
val intent = Intent(ACTION_CHAMELEON).apply {
data = Uri.Builder().scheme(DATA_SCHEME).authority(DATA_HOST).build()
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryBroadcastReceivers(
intent,
ResolveInfoFlags.of(GET_RECEIVERS.toLong()),
)
} else {
context.packageManager.queryBroadcastReceivers(intent, GET_RECEIVERS)
}
}
private fun convert(resolveInfo: ResolveInfo): ChameleonPage {
val packageName = resolveInfo.activityInfo.packageName
val className = resolveInfo.activityInfo.name
val receiverInfo = context.packageManager.getReceiverInfo(
ComponentName(packageName, className),
GET_META_DATA,
)
val resId = receiverInfo.metaData.getInt(KEY_CHAMELEON_PAGE)
return ChameleonPage(packageName, resId)
}
解析
在扫描到 XML 文件的 resource id 之后,可以使用 XML 解析器对文件进行解析。这部分逻辑在示例工程的 core 模块的 parse 包中。为了可扩展性和可测试性,这里有两个设计:
- 可替换的 XML 解析器
- 可扩展的 UI 元素解析器
其中可替换的xml解析器是定义的 ParserProvider 接口
interface ParserProvider {
fun get(page: ChameleonPage): XmlPullParser?
}
它有一个实现类
class XMLResourceParserProvider(val context: Context) : ParserProvider {
override fun get(page: ChameleonPage): XmlPullParser? {
return try {
context.createPackageContext(page.packageName, 0).resources.getXml(page.resId)
} catch (e: PackageManager.NameNotFoundException) {
Log.e(TAG, "get NameNotFoundException", e)
null
} catch (e: Resources.NotFoundException) {
Log.e(TAG, "get NotFoundException", e)
null
}
}
companion object {
private const val TAG = "XMLResourceParserProvider"
}
}
可扩展的 UI 元素解析器是定义的 ElementParser 接口
interface ElementParser {
fun parse(parser: XmlPullParser, packageName: String): Element?
}
根据 XML 文件中的 tag 名,分别有 TextParser, ButtonParser , ToggleParser, DropdownParser, GroupParser, PageParser 。基于这个设计,根据需求可以增加更多的 UI 元素和解析器。以 ButtonParser 为例,解析 Button Tag 的代码是
/**
* Button xml format
*
* <Button
* click="click_button"
* key="key_test_button"
* label="Test Button" />
*/
class ButtonParser : ElementParser {
override fun parse(parser: XmlPullParser, packageName: String): Button {
val key = parser.getAttributeValue(null, ATTR_KEY)
val label = parser.getAttributeValue(null, ATTR_LABEL)
val click = parser.getAttributeValue(null, ATTR_CLICK)
return Button(
packageName = packageName,
key = key,
label = label,
click = click,
)
}
companion object {
const val TAG = "Button"
}
}
最后建立一个 XML tag name 和 EmementParser 的对应关系,根据 tag name 的不同,调用不同的解析器进行解析,这里的逻辑在 ParserManager 。
object ParserManager {
private val parsers: Map<String, ElementParser> = mapOf(
PageParser.TAG to PageParser(),
GroupParser.TAG to GroupParser(),
TextParser.TAG to TextParser(),
ButtonParser.TAG to ButtonParser(),
ToggleParser.TAG to ToggleParser(),
DropdownParser.TAG to DropdownParser(),
)
fun parse(parser: XmlPullParser, packageName: String): Element? {
return parsers[parser.name]?.parse(parser, packageName)
}
}
整个解析 XML 的过程我们使用 XmlPullParser ,它是 Android SDK中自带的 XML 文件解析器,并基于事件驱动的方式解析XML,解析的过程可以读取当前的 event type,根据事件做相应的处理。
XmlPullParser 的文档可参考 developer.android.com/reference/o…
以上完成单一 UI 元素的解析,下一步是完成树状结构的页面布局,将 UI 元素作为 Page 或 Group 的子元素。大致过程是
简单来说就是把 Page 和 Group这样可以包含子元素的元素解析之后放到栈里,然后将 Text、Button 这样不能包含子元素的元素添加到栈顶元素(可能是Page或Group)的内部。 这部分代码在示例工程的 Parser.kt。
UI
这一步是将解析之后的数据显示出来,这里页面层级图。
.png)
CategoryFragment是将扫描到的所有配置页面,根据 category 分类之后显示这些分类信息PagesFragment是显示某个 category 分类下的所有页面的标题PageFragment是显示某个页面里的所有页面元素,并可以嵌套显示
此章节详细讲一下 PageFragment 页面中页面元素如何显示。为了代码的可扩展性和可测试性,做了以下设计:
- 定义
ItemModel接口,抽象不同RecyclerViewItem 的共性到 ItemModel - 定义
BaseViewHolder类,统一不同RecyclerViewItem 的显示逻辑
ItemMode.kt
interface ItemModel {
val id: String
@ItemType
val type: Int
val packageName: String
fun value(context: Context, callback: (String?) -> Unit) {}
fun click(context: Context, vararg args: String?, callback: (String?) -> Unit)
}
BaseViewHolder.kt
abstract class BaseViewHolder<T : ItemModel, VB : ViewBinding>(val binding: VB) :
RecyclerView.ViewHolder(binding.root) {
abstract fun bind(item: T)
open fun unbind() {}
}
然后定义一个 Factory类,根据不同 view type 生成 BaseViewHolder。
ViewHolderFactory.kt
interface ViewHolderFactory {
fun create(parent: ViewGroup, viewType: Int): BaseViewHolder<ItemModel, ViewBinding>
}
class PageViewHolderFactory(private val navigateToPage: (Page) -> Unit) : ViewHolderFactory {
@Suppress("UNCHECKED_CAST")
override fun create(
parent: ViewGroup,
@ItemType viewType: Int,
): BaseViewHolder<ItemModel, ViewBinding> {
val item = when (viewType) {
ITEM_TYPE_PAGE -> PageViewHolder(parent, navigateToPage)
ITEM_TYPE_TEXT -> TextViewHolder(parent)
ITEM_TYPE_BUTTON -> ButtonViewHolder(parent)
ITEM_TYPE_TOGGLE -> ToggleViewHolder(parent)
ITEM_TYPE_DROPDOWN -> DropdownViewHolder(parent)
ITEM_TYPE_GROUP_START -> GroupStartViewHolder(parent)
ITEM_TYPE_GROUP_END -> GroupEndViewHolder(parent)
else -> throw IllegalArgumentException("Unknown view type: $viewType")
}
return item as BaseViewHolder<ItemModel, ViewBinding>
}
}
在 PageAdapter.kt 中
class PageAdapter(
private val viewHolderFactory: ViewHolderFactory,
) : RecyclerView.Adapter<BaseViewHolder<ItemModel, ViewBinding>>() {
private val items: MutableList<ItemModel> = mutableListOf()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): BaseViewHolder<ItemModel, ViewBinding> {
return viewHolderFactory.create(parent, viewType)
}
override fun onBindViewHolder(holder: BaseViewHolder<ItemModel, ViewBinding>, position: Int) {
holder.bind(items[position])
}
override fun onViewRecycled(holder: BaseViewHolder<ItemModel, ViewBinding>) {
super.onViewRecycled(holder)
holder.unbind()
}
override fun getItemViewType(position: Int): Int = items[position].type
override fun getItemCount(): Int = items.size
@SuppressLint("NotifyDataSetChanged")
fun update(items: List<ItemModel>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
}
以上将框架搭建完成,现在需要实现不同类型的 UI 元素,以 Button 为例
ButtonItemModel.kt
data class ButtonItemModel(
val button: Button,
override val packageName: String,
override val id: String = UUID.randomUUID().toString(),
override val type: Int = ITEM_TYPE_BUTTON,
) : ItemModel {
override fun click(context: Context, vararg args: String?, callback: (String?) -> Unit) {
if (button.click.isEmpty()) return
send(context, button.click, callback)
}
}
ButtonViewHolder.kt
class ButtonViewHolder(private val parent: ViewGroup) :
BaseViewHolder<ButtonItemModel, ItemButtonBinding>(newViewBinding(parent)) {
override fun bind(item: ButtonItemModel) {
binding.apply {
text.text = item.button.label
text.setOnClickListener {
item.click(parent.context) { value ->
Toast.makeText(parent.context, value, Toast.LENGTH_SHORT).show()
}
}
}
}
}
通信
这部分描述如何在 Host 和 Client 应用之间的通信。在上一篇 《Android 动态页面框架 (一)》最后我们提到 AIDL 和广播两种方式实现通信,他们各有优势和劣势。在示例工程中使用广播方式,大家可以根据项目需要采用其他方式。
还是以 Button 为例,当用户点击 Button 时会调用 ButtonItemModel 的 click 方法并通过callback 监听结果。
data class ButtonItemModel(
val button: Button,
override val packageName: String,
override val id: String = UUID.randomUUID().toString(),
override val type: Int = ITEM_TYPE_BUTTON,
) : ItemModel {
override fun click(context: Context, vararg args: String?, callback: (String?) -> Unit) {
if (button.click.isEmpty()) return
send(context, button.click, callback)
}
}
send 方法是定义在 ItemMode 接口中,向特定的应用发送广播并接收返回值。这里的 packageName 可以指定到某一个 Client 应用,而不是发给所有应用,使用 uri 的方式将点击的 UI 元素的 key 发送给 Client 应用。
fun send(context: Context, path: String, callback: (String?) -> Unit) {
val intent = Intent().apply {
component = ComponentName(packageName, RECEIVER_CLASS_NAME)
action = ACTION_CHAMELEON
data = Uri.Builder().scheme(DATA_SCHEME)
.authority(DATA_HOST)
.path(path)
.build()
}
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val value = getResultExtras(true).getString(KEY_RESULT)
callback.invoke(value)
}
}
context.sendOrderedBroadcast(
intent,
PERMISSION_CHAMELEON,
receiver,
null,
Activity.RESULT_OK,
null,
null,
)
}
作为 Client 应用,接收广播并根据广播中 path 处理不同的逻辑。这里 Client 应用需要创建一个 BroadcastReceiver,接收广播并处理逻辑,然后返回结果给 Host 应用。这部分逻辑可以封装到 SDK 中,减少 Client 应用的代码量,降低集成的难度。
首先, 抽象一个 ChameleonProcessor 类,Client 应用只需要继承这个类,实现其中的抽象方法处理不同的 path。
abstract class ChameleonProcessor {
abstract fun processAction(
context: Context,
receiver: BroadcastReceiver,
intent: Intent,
action: String?,
vararg args: String?,
)
fun setResult(receiver: BroadcastReceiver, result: String) {
val bundle = Bundle().apply {
putString(KEY_RESULT, result)
}
receiver.setResultExtras(bundle)
}
}
而 BroadcastReceiver 通过反射的方式调用 ChameleonProcessor 的 processAction 方法。
在配置文件的头部添加 processor 字段,声明实现 ChameleonProcessor 接口的类路径。
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE Chameleon SYSTEM "chameleon.dtd">
<Chameleon processor="com.wx.chameleon.sample.SampleProcessor">
<Page
title="Test Page"
category="Test"
key="key_test_page"
label="Test Page">
...
</Page>
...
</Chameleon>
通过反射得到这个类的对象
fun get(): ChameleonProcessor? {
val className = getProcessorClassName() // 从xml文件中解析类名
if (className.isNullOrEmpty()) {
Log.e(TAG, "can not get processor class name")
return null
}
return try {
val classType = Class.forName(className)
classType.getDeclaredConstructor().newInstance() as? ChameleonProcessor
} catch (e: Exception) {
Log.e(TAG, "can not create processor.", e)
null
}
}
在 ChameleonReceiver 中调用 ChameleonProcessor 的 processAction 方法
class ChameleonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val segments = Uri.parse(intent.data?.path).pathSegments
ProcessorFactory.get(context)?.processAction(
context,
this,
intent,
segments[0],
*segments.subList(1, segments.size).toTypedArray(),
)
}
}
Client 应用只需要实现 ChameleonProcessor 接口即可。
结语
至此,动态化页面框架的代码实现部分结束,完整代码可参考 github 项目 ChameleonPage。