动态

详情 返回 返回

Launcher 卡片框架多模塊集成 - 动态 详情

方案一、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) 創建卡片顯示在桌面 

user avatar woblog 头像
点赞 1 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.