方案一、aar架包集成
最簡單直接的方案,卡片側實現(xiàn),打成aar包提供到launcher顯示
方案二、AppWidget
原生的桌面小組件方案,被限制無法自定義view
底層通過BroadcastReceiver實現(xiàn)
方案三、插件方案
插件方案有好幾種,實現(xiàn)原理都是通過配置實現(xiàn),其中有Service,BroadcastReceiver,Plugin
在SystemUI模塊中,狀態(tài)欄等模塊很多使用的都是Plugin方案跟Service方案
這里詳細講通過Service配置跟Plugin配置實現(xiàn)
插件方案可以實現(xiàn)卡片跟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>
package com.example.page import android.content.Context interface Plugin { fun onCreate(hostContext: Context, pluginContext: Context) { } fun onDestroy() { } } class PagerWidgetPlugin : 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 } }
上面插件是直接定義在卡片里,其實應該在launcher中,然后對所有的卡片提供基礎aar,統(tǒng)一接口
然后在res/xml下面新建 widget_info.xml
<?xml version="1.0" encoding="utf-8"?> <com-appwidget-provider cardType="0" mediumLayout="@layout/pager_control_layout" />
<?xml version="1.0" encoding="utf-8"?> <com-appwidget-provider cardType="0" smallLayout="@layout/cards_remote_control_layout" />
編寫卡片布局
<?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>
<?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" />
然后在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 } } } }
也可以通過加載器獲取
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) } }
因為處于不用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) } } } }
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) } }
class PluginApplication(context: Context) : Application() { init { attachBaseContext(context) } }
獲取到卡片的context跟classloader后,傳入到 PluginContextWrapper 中,用于后續(xù)卡片內加載布局
通過PathClassLoader構建的類加載器包含了插件APK的路徑,當調用LayoutInflater.inflate()時,系統(tǒng)會通過getClassLoader()獲取這個自定義加載器來實例化插件中的自定義View類
類中重寫了 getSystemService(),返回自定義的LayoutInflater,這個inflater綁定了插件的Context,確保資源解析的正確性
setOuterContext()將宿主Context設置為OuterContext,這樣在插件中啟動Activity、注冊廣播等操作時,系統(tǒng)會使用宿主環(huán)境來執(zhí)行這些跨進程操作
上面操作確保插件中的類加載、資源訪問和組件交互都能在正確的環(huán)境中執(zhí)行
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() } } }
在launcher中直接 CardHostView.obtain(mBinding.root.context,it) 創(chuàng)建卡片顯示在桌面
浙公網安備 33010602011771號