Android 动态页面框架 (二)

996 阅读8分钟

版权声明

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

© 2024 小酥肉不加辣,All rights reserved.

MAD App Architecture launch - Mobile.png

上一篇 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 文件中,核心逻辑是调用 PackageManagerqueryBroadcastReceivers 方法扫描其他应用的 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 包中。为了可扩展性和可测试性,这里有两个设计:

  1. 可替换的 XML 解析器
  2. 可扩展的 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 的子元素。大致过程是

SDD_diagram_parser_flow.png 简单来说就是把 Page 和 Group这样可以包含子元素的元素解析之后放到栈里,然后将 Text、Button 这样不能包含子元素的元素添加到栈顶元素(可能是Page或Group)的内部。 这部分代码在示例工程的 Parser.kt。

UI

这一步是将解析之后的数据显示出来,这里页面层级图。

dynamic page ui structure.drawio (1).png转存失败,建议直接上传图片文件

  • CategoryFragment 是将扫描到的所有配置页面,根据 category 分类之后显示这些分类信息
  • PagesFragment 是显示某个 category 分类下的所有页面的标题
  • PageFragment 是显示某个页面里的所有页面元素,并可以嵌套显示

此章节详细讲一下 PageFragment 页面中页面元素如何显示。为了代码的可扩展性和可测试性,做了以下设计:

  1. 定义 ItemModel 接口,抽象不同 RecyclerView Item 的共性到 ItemModel
  2. 定义 BaseViewHolder 类,统一不同 RecyclerView Item 的显示逻辑

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 通过反射的方式调用 ChameleonProcessorprocessAction 方法。

在配置文件的头部添加 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