惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

美团技术团队
N
Netflix TechBlog - Medium
WordPress大学
WordPress大学
云风的 BLOG
云风的 BLOG
J
Java Code Geeks
V
Visual Studio Blog
H
Help Net Security
Engineering at Meta
Engineering at Meta
Hugging Face - Blog
Hugging Face - Blog
Microsoft Security Blog
Microsoft Security Blog
腾讯CDC
博客园 - 【当耐特】
B
Blog
Stack Overflow Blog
Stack Overflow Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
大猫的无限游戏
大猫的无限游戏
GbyAI
GbyAI
博客园 - 司徒正美
博客园 - 叶小钗
Y
Y Combinator Blog
MyScale Blog
MyScale Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
G
Google Developers Blog
酷 壳 – CoolShell
酷 壳 – CoolShell

博客园 - 翻滚的咸鱼

Ops权限行为记录源码解析 TaskView方案框架原理 视频解码器问题 常用命令 扫光动效 引导记录 性能优化 View共享动效 常用脚本文件 Launcher 桌面源码笔记二 Launcher 桌面源码笔记一 氛围灯动态屏保取色方案二 氛围灯动态屏保取色方案一 自定义最近任务管理器 anr问题 TV RecyclerView 焦点处理笔记 多类型适配器 多屏下字体自动取色 阻尼拉伸方案 图片区域点击处理 RecyclerView设置默认焦点跟多页面焦点抢占 TV RecyclerView 滑动后操作保持落焦在左侧第一个View Fragment翻盖动效 当个View下,使用Drawable入场退场动画
Launcher 卡片框架多模块集成
翻滚的咸鱼 · 2025-10-30 · via 博客园 - 翻滚的咸鱼

方案一、aar架包集成

最简单直接的方案,卡片侧实现,打成aar包提供到launcher显示

方案二、AppWidget

原生的桌面小组件方案,被限制无法自定义view

底层通过BroadcastReceiver实现

方案三、插件方案

插件方案有好几种,实现原理都是通过配置实现,其中有Service,BroadcastReceiver,Plugin

在SystemUI模块中,状态栏等模块很多使用的都是Plugin方案跟Service方案

这里详细讲通过Service配置跟Plugin配置实现

插件方案可以实现卡片跟launcher解耦,并且可以自定义view,还支持跨进程交互

首先定义一个插件,用于配置卡片信息,exported 属性标识可以给其它应用读取

<service
            android:name=".TestWidgetService"
            android:exported="true"
            android:label="测试卡片1">
            <intent-filter>
                <action android:name="com.appwidget.action.rear.APPWIDGET_PLUGIN" />
            </intent-filter>

            <meta-data
                android:name="com.appwidget.provider"
                android:resource="@xml/remote_control_widget_info" />
        </service>

        <service
            android:name=".PagerWidgetPlugin"
            android:exported="true"
            android:label="测试卡片2">
            <intent-filter>
                <action android:name="com.appwidget.action.rear.APPWIDGET_PLUGIN" />
            </intent-filter>

            <meta-data
                android:name="com.appwidget.provider"
                android:resource="@xml/pager_widget_info" />
        </service>

View Code

package com.example.page

import android.content.Context

interface Plugin {

    fun onCreate(hostContext: Context, pluginContext: Context) {
    }

    fun onDestroy() {
    }
}

class PagerWidgetPlugin : Plugin

Plugin

package com.example.page

import android.app.Service
import android.content.Intent
import android.os.IBinder

class TestWidgetService : Service() {
    override fun onBind(intent: Intent?): IBinder? {
        return null
    }
}

Service

上面插件是直接定义在卡片里,其实应该在launcher中,然后对所有的卡片提供基础aar,统一接口

然后在res/xml下面新建 widget_info.xml

<?xml version="1.0" encoding="utf-8"?>
<com-appwidget-provider
    cardType="0"
    mediumLayout="@layout/pager_control_layout" />

pager_widget_info

<?xml version="1.0" encoding="utf-8"?>
<com-appwidget-provider
    cardType="0"
    smallLayout="@layout/cards_remote_control_layout" />

remote_control_widget_info

编写卡片布局

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    android:focusable="false">

    <ImageView
        android:id="@+id/card_remote_control_image"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <TextView
        android:id="@+id/card_remote_control_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="32dp"
        android:drawableLeft="@mipmap/ic_launcher_round"
        android:drawablePadding="8dp"
        android:text="title"
        android:textColor="@android:color/holo_blue_dark"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/card_remote_control_tips"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="4dp"
        android:ellipsize="end"
        android:maxWidth="390dp"
        android:singleLine="true"
        android:text="tips"
        android:textColor="@android:color/holo_orange_dark"
        app:layout_constraintBottom_toTopOf="@+id/card_remote_control_summary"
        app:layout_constraintStart_toStartOf="@+id/card_remote_control_summary" />

    <TextView
        android:id="@+id/card_remote_control_summary"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="32dp"
        android:layout_marginBottom="35dp"
        android:ellipsize="end"
        android:maxWidth="405dp"
        android:singleLine="true"
        android:text="content"
        android:textColor="@android:color/holo_blue_bright"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

cards_remote_control_layout

<?xml version="1.0" encoding="utf-8"?>
<com.example.page.loop.CustomViewPager xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white" />

pager_control_layout

然后在launcher中,使用 AppWidgetManager 来读取配置信息

package com.test.launcher.rear.card.appwidget

import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.util.Log
import com.blankj.utilcode.util.GsonUtils
import com.kunminx.architecture.ui.callback.UnPeekLiveData
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException

@SuppressLint("StaticFieldLeak")
object AppWidgetManager {

    val context: Context = android.app.AppGlobals.getInitialApplication()

    private const val ACTION = "com.appwidget.action.rear.APPWIDGET_PLUGIN"

    private const val META_DATA_APPWIDGET_PROVIDER: String = "com.appwidget.provider"

    private val list = mutableListOf<CardModel>()
    private var mAppWidgetChangeListener: ((MutableList<CardModel>) -> Unit)? = null
    val showOnCards = UnPeekLiveData(mutableListOf<CardModel>())

    init {
        val intent = Intent(ACTION)
        val resolveInfoList = context.packageManager.queryIntentServices(
            intent,
            PackageManager.GET_META_DATA or PackageManager.GET_SHARED_LIBRARY_FILES
        )
        Logger.d("resolveInfoList size ${resolveInfoList.size}")
        resolveInfoList.forEach { ri ->
            parseAppWidgetProviderInfo(ri)
        }
    }

    var id = 0

    fun allocateAppWidgetId(): Int {
        return ++id
    }

    fun setAppWidgetChangeListener(listener: ((MutableList<CardModel>) -> Unit)?) {
        mAppWidgetChangeListener = listener
    }


    private fun parseAppWidgetProviderInfo(resolveInfo: ResolveInfo) {
        val componentName =
            ComponentName(resolveInfo.serviceInfo.packageName, resolveInfo.serviceInfo.name)
        val serviceInfo = resolveInfo.serviceInfo

        val hasXmlDefinition = serviceInfo.metaData?.getInt(META_DATA_APPWIDGET_PROVIDER) != 0

        if (hasXmlDefinition) {
            val info = CardInfo()
            info.serviceInfo = serviceInfo
            info.componentName = componentName
            val pm = context.packageManager
            try {
                serviceInfo.loadXmlMetaData(pm, META_DATA_APPWIDGET_PROVIDER).use { parser ->
                    if (parser == null) {
                        Logger.w("$componentName parser is null")
                        return
                    }

                    val nodeName: String = parser.name
                    if ("com-appwidget-provider" != nodeName) {
                        Logger.w("$componentName provider is null")
                        return
                    }

                    info.descriptionRes =
                        parser.getAttributeResourceValue(null, "description", 0)

                    info.mediumLayout =
                        parser.getAttributeResourceValue(null, "mediumLayout", 0)
                    info.mediumPreviewImage =
                        parser.getAttributeResourceValue(null, "mediumPreviewImage", 0)

                    info.smallLayout =
                        parser.getAttributeResourceValue(null, "smallLayout", 0)
                    if (info.smallLayout != 0) {
                        info.sizeStyle = 1
                    }
                    info.smallPreviewImage =
                        parser.getAttributeResourceValue(null, "smallPreviewImage", 0)

                    info.bigLayout =
                        parser.getAttributeResourceValue(null, "bigLayout", 0)
                    info.bigPreviewImage =
                        parser.getAttributeResourceValue(null, "bigPreviewImage", 0)
                    if (info.bigLayout != 0) {
                        info.sizeStyle = 2
                    }
                    Logger.d("parseAppWidgetProviderInfo $componentName hasLayout=${info.hasLayout()}")
                    if (info.hasLayout()) {
                        list.add(CardModel(allocateAppWidgetId(), info, false))
                    }
                    return
                }
            } catch (e: IOException) {
                // Ok to catch Exception here, because anything going wrong because
                // of what a client process passes to us should not be fatal for the
                // system process.
                Logger.e("XML parsing failed for AppWidget provider $componentName", e)
                return
            } catch (e: PackageManager.NameNotFoundException) {
                Logger.e("XML parsing failed for AppWidget provider $componentName", e)
                return
            } catch (e: XmlPullParserException) {
                Logger.e("XML parsing failed for AppWidget provider $componentName", e)
                return
            }
        }
    }
}

View Code

也可以通过加载器获取

private fun parseAppWidgetProviderInfo(resolveInfo: ResolveInfo) {
        val componentName =
            ComponentName(resolveInfo.serviceInfo.packageName, resolveInfo.serviceInfo.name)

        val serviceInfo = resolveInfo.serviceInfo
        val pluginContext = PluginContextWrapper.createFromPackage(serviceInfo.packageName)

        try {
            val cardPlugin = Class.forName(
                serviceInfo.name, true, pluginContext.classLoader
            ).newInstance() as CardPlugin

            cardPlugin.onCreate(context, pluginContext)
        } catch (e: Exception) {
            Log.w(TAG, "parseAppWidgetProviderInfo failed for AppWidget provider $componentName", e)
        }
    }

View Code

因为处于不用apk,所以加载卡片类,需要加载其他路径的类文件,需要把这个类文件路径加到自己的classloader

package com.test.carlauncher.cards.plugin

import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.text.TextUtils
import android.view.LayoutInflater
import dalvik.system.PathClassLoader
import java.io.File

class PluginContextWrapper(
    base: Context,
    private val classLoader: ClassLoader = ClassLoaderFilter(base.classLoader)
) : ContextWrapper(base) {

    private val application: Application by lazy {
        PluginApplication(this)
    }

    private val mInflater: LayoutInflater by lazy {
        LayoutInflater.from(baseContext).cloneInContext(this)
    }

    override fun getClassLoader(): ClassLoader {
        return classLoader
    }

    override fun getApplicationContext(): Context {
        return application
    }

    override fun getSystemService(name: String): Any {
        if (LAYOUT_INFLATER_SERVICE == name) {
            return mInflater
        }
        return baseContext.getSystemService(name)
    }


    override fun toString(): String {
        return "${javaClass.name}@${Integer.toHexString(hashCode())}_$packageName"
    }


    companion object {
        private val contextMap = mutableMapOf<String, Context>()

        private val methodSetOuterContext = Class.forName("android.app.ContextImpl")
            .getDeclaredMethod("setOuterContext", Context::class.java).apply {
                isAccessible = true
            }

        private fun Context.setOuterContext(outContext: Context) {
            methodSetOuterContext.invoke(this, outContext)
        }

        fun createFromPackage(packageName: String): Context {
            val contextCache = contextMap.get(packageName)
            if (contextCache != null) {
                return contextCache
            }
            val hostContext: Context = android.app.AppGlobals.getInitialApplication()
            val appInfo = hostContext.packageManager.getApplicationInfo(packageName, 0)
            val appContext: Context = hostContext.createApplicationContext(
                appInfo,
                CONTEXT_INCLUDE_CODE or CONTEXT_IGNORE_SECURITY
            )

            val zipPaths = mutableListOf<String>()
            val libPaths = mutableListOf<String>()
            android.app.LoadedApk.makePaths(null, true, appInfo, zipPaths, libPaths);
            val classLoader = PathClassLoader(
                TextUtils.join(File.pathSeparator, zipPaths),
                TextUtils.join(File.pathSeparator, libPaths),
                ClassLoaderFilter(hostContext.classLoader)
            )

            // 注册广播、绑定服务、startActivity会使用OuterContext
            // (appContext as android.app.ContextImpl).setOuterContext(context)
            appContext.setOuterContext(hostContext)

            return PluginContextWrapper(appContext, classLoader).also {
                contextMap.put(packageName, it)
            }
        }
    }
}

View Code

class ClassLoaderFilter(
    private val mBase: ClassLoader,
    private val mPackages: Array<String>
) : ClassLoader(getSystemClassLoader()) {


    @Throws(ClassNotFoundException::class)
    override fun loadClass(name: String, resolve: Boolean): Class<*> {
        for (pkg in mPackages) {
            if (name.startsWith(pkg)) {
                return mBase.loadClass(name)
            }
        }
        return super.loadClass(name, resolve)
    }
}

View Code

class PluginApplication(context: Context) : Application() {

    init {
        attachBaseContext(context)
    }
}

View Code

获取到卡片的context跟classloader后,传入到 PluginContextWrapper 中,用于后续卡片内加载布局

通过PathClassLoader构建的类加载器包含了插件APK的路径,当调用LayoutInflater.inflate()时,系统会通过getClassLoader()获取这个自定义加载器来实例化插件中的自定义View类

类中重写了 getSystemService(),返回自定义的LayoutInflater,这个inflater绑定了插件的Context,确保资源解析的正确性

setOuterContext()将宿主Context设置为OuterContext,这样在插件中启动Activity、注册广播等操作时,系统会使用宿主环境来执行这些跨进程操作

上面操作确保插件中的类加载、资源访问和组件交互都能在正确的环境中执行

接下来将卡片布局加载到统一的容器中,在容器内加载布局启动activity等操作都使用的卡片context

package com.test.launcher.rear.card.appwidget

import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.view.Display
import android.view.Gravity
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.view.children

class CardHostView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
    private lateinit var contentView: View
    private var decoratorView: View? = null
    var cardInfo: CardInfo? = null

    var initialLayout = 0
        set(value) {
            field = value
            apply()
        }

    fun apply() {
        contentView = getDefaultView()
        removeAllViews()
        contentView.setCorner(getDimen(baseDimen.baseapp_auto_dp_32).toFloat())
        addView(contentView, LayoutParams(-1, -1))
    }

    fun getDefaultView(): View {
        var defaultView: View? = null
        try {
            val layoutId: Int = initialLayout
            defaultView = LayoutInflater.from(context).inflate(layoutId, this, false)
            setOnClickListener {
                defaultView?.callOnClick()
            }
        } catch (exception: RuntimeException) {
            Logger.e("Error inflating AppWidget $cardInfo", exception)
        }

        if (defaultView == null) {
            Logger.w("getDefaultView couldn't find any view, so inflating error")
            defaultView = getErrorView()
        }
        return defaultView
    }

    override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
        return !(parentView()?.inEditeMode ?: false) && super.dispatchKeyEvent(event)
    }

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        return !(parentView()?.inEditeMode ?: false) && super.dispatchTouchEvent(ev)
    }


    fun exitEditeMode() {
        decoratorView?.let {
            removeView(it)
        }
    }

    private fun getErrorView(): View {
        val tv = TextView(context)
        tv.gravity = Gravity.CENTER
        tv.setText(com.android.internal.R.string.gadget_host_error_inflating)
        tv.setBackgroundColor(Color.argb(127, 0, 0, 0))
        return tv
    }

    fun getContentView(): View {
        return contentView
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        Logger.d("${contentView::class.java.name}#${contentView.hashCode()} onAttachedToWindow")
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        Logger.d("${contentView::class.java.name}#${contentView.hashCode()} onDetachedFromWindow")
    }


    fun View.parentView() = parent?.parent as? FocusLimitRecycleView

    companion object {
        fun obtain(context: Context, card: CardModel): CardHostView {
            val packageName = card.info.componentName.packageName
            val pluginContext =
                if (packageName == context.packageName) context else
                    PluginContextWrapper.createFromPackage(packageName, context.display)
            return CardHostView(pluginContext).also {
                it.id = View.generateViewId()
                it.isFocusable = false
                it.cardInfo = card.info
                it.initialLayout = when (card.info.sizeStyle) {
                    1 -> card.info.smallLayout
                    3 -> card.info.bigLayout
                    else -> card.info.mediumLayout
                }
            }
        }
    }

    open fun updateChildState(it: Boolean, recyclerView: FocusLimitRecycleView) {
        val inTouchMode = recyclerView.isInTouchMode
        val hasFocus = recyclerView.hasFocus()
        val parent = parent as? ViewGroup
        Logger.d("parent isInTouchMode $inTouchMode $hasFocus")
        if (it) {
            if (hasFocus && !inTouchMode) {
                if (recyclerView.getEditeChild() == parent?.tag) {
                    parent?.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
                    getContentView().alpha = 1f
                } else {
                    parent?.descendantFocusability = FOCUS_AFTER_DESCENDANTS
                    getContentView().alpha = 0.4f
                }
            }
        } else {
            getContentView().alpha = 1f
            parent?.visible()
        }
    }
}

View Code

在launcher中直接 CardHostView.obtain(mBinding.root.context,it) 创建卡片显示在桌面