欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

腾讯新开源的插件化框架 Shadow,原来是这么玩的

程序员文章站 2022-03-12 21:55:02
/ 今日科技快讯 /8月27日,哔哩哔哩发布了2019年第二季度财报。月活用户连续攀升和营收结构趋于平衡,成为本季度B站成绩单的关键词。根据财报显示,第二季度,B......

腾讯新开源的插件化框架 Shadow,原来是这么玩的


/   今日科技快讯   /


8月27日,哔哩哔哩发布了2019年第二季度财报。月活用户连续攀升和营收结构趋于平衡,成为本季度B站成绩单的关键词。根据财报显示,第二季度,B站月活用户达到1.1亿,实现了900万的环比净增,创造了B站自2017年以来单季增长的记录。在此基础上,移动端月活达9620万,同比增长35%;日活用户达3320万,同比增长41%。


/   作者简介   /


本篇文章来自ZY5A59的投稿,分享了他对Shadow插件化框架的相关理解,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。


ZY5A59的博客地址:

https://juejin.im/user/58d9d015ac502e0058df1f96


/   框架简单介绍   /


Shadow是最近腾讯开源的一款插件化框架。原理是使用宿主代理的方式实现组件的生命周期。目前的插件化框架,大部分都是使用hook系统的方式来做的。使用代理的基本上没有成体系的框架,只是一些小demo,Shadow框架的开源,在系统api 控制越来越严格的趋势下,算是一个新的方向。Shadow最大的两个亮点是:


  1. 零反射

  2. 框架自身动态化


下面就具体分析一下框架的实现。


Shadow框架的开源地址:

https://github.com/Tencent/Shadow



腾讯新开源的插件化框架 Shadow,原来是这么玩的


/   框架结构分析   /


框架结构图


腾讯新开源的插件化框架 Shadow,原来是这么玩的


项目目录结构



├── projects
│   ├── sample // 示例代码
│   │   ├── README.md
│   │   ├── maven
│   │   ├── sample-constant // 定义一些常量
│   │   ├── sample-host // 宿主实现
│   │   ├── sample-manager // PluginManager 实现
│   │   └── sample-plugin // 插件的实现
│   ├── sdk // 框架实现代码
│   │   ├── coding // lint
│   │   ├── core
│   │   │   ├── common
│   │   │   ├── gradle-plugin // gradle 插件
│   │   │   ├── load-parameters
│   │   │   ├── loader // 负责加载插件
│   │   │   ├── manager // 装载插件,管理插件
│   │   │   ├── runtime // 插件运行时需要,包括占位 Activity,占位 Provider 等等
│   │   │   ├── transform // Transform 实现,用于替换插件 Activity 父类等等
│   │   │   └── transform-kit
│   │   └── dynamic // 插件自身动态化实现,包括一些接口的抽象


框架主要类说明


PluginContainerActivity


代理Activity。


ShadowActivity


插件Activity统一父类,在打包时通过Transform统一替换。


ComponentManager


管理插件和宿主代理的对应关系。


PluginManager


装载插件。


PluginLoader


管理插件Activity生命周期等等。


sample示例代码 AndroidManifest.xml分析


注册sample MainActivity


负责启动插件。


注册代理Activity


注册了三个代理Activity,分别是 PluginDefaultProxyActivity,PluginSingleInstance1ProxyActivity,PluginSingleTask1ProxyActivity。可以看到,这三个Activity都是继承自PluginContainerActivity,只是设置了不同的launchMode,这里就明显的看出来,PluginContainerActivity就是代理Activity。


注册代理Provider


PluginContainerContentProvider也是代理Provider。


Activity实现


关于插件Activity的实现,我们主要看两个地方:


替换插件Activity的父类


  • 宿主中如何启动插件Activity

  • 插件中如何启动插件Activity


替换插件Activity的父类


Shadow中有一个比较巧妙的地方,就是插件开发的时候,插件的Activity还是正常继承Activity,在打包的时候,会通过Transform替换其父类为ShadowActivity。

projects/sdk/core/transform 和 projects/sdk/core/transform-kit 两个项目就是Transform,入口是ShadowTransform。这里对Transform做了一些封装,提供了友好的开发方式,这里就不多做分析了,我们主要看下TransformManager。



class TransformManager(ctClassInputMapMap<CtClassInputClass>,
                       classPoolClassPool,
                       useHostContext: () -> Array<String>
) : AbstractTransformManager(ctClassInputMapclassPool
{

    override val mTransformList: List<SpecificTransform> = listOf(
            ApplicationTransform(),
            ActivityTransform(),
            ServiceTransform(),
            InstrumentationTransform(),
            RemoteViewTransform(),
            FragmentTransform(ctClassInputMap),
            DialogTransform(),
            WebViewTransform(),
            ContentProviderTransform(),
            PackageManagerTransform(),
            KeepHostContextTransform(useHostContext())
    )
}


这里的mTransformList就是要依次执行的Transform内容,也就是需要替换的类映射。我们以ApplicationTransform和ActivityTransform为例。



class ApplicationTransform : SimpleRenameTransform(
        mapOf(
                "android.app.Application"
                        to "com.tencent.shadow.core.runtime.ShadowApplication"
                ,
                "android.app.Application\$ActivityLifecycleCallbacks"
                        to "com.tencent.shadow.core.runtime.ShadowActivityLifecycleCallbacks"
        )
)

class ActivityTransform : SimpleRenameTransform(
        mapOf(
                "android.app.Activity"
                        to "com.tencent.shadow.core.runtime.ShadowActivity"
        )
)


可以看到,打包过程中,插件的Application会被替换成ShadowApplication,Activity会被替换成ShadowActivity,这里主要看一下ShadowActivity的继承关系。


腾讯新开源的插件化框架 Shadow,原来是这么玩的


为何插件Activity可以不用继承Activity呢?因为在代理Activity的方式中,插件Activity是被当作一个普通类来使用的,只要负责执行对应的生命周期即可。


宿主中如何启动插件Activity


宿主中启动插件Activity原理如下图:


腾讯新开源的插件化框架 Shadow,原来是这么玩的


我们就从sample里的MainActivity开始看起。


sample-host/src/main/java/com/tencent/shadow/sample/host/MainActivity是demo的主入口。启动插件的方式是:



startPluginButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // ...
        Intent intent = new Intent(MainActivity.this, PluginLoadActivity.class);
        intent.putExtra(Constant.KEY_PLUGIN_PART_KEY, partKey);
        intent.putExtra(Constant.KEY_ACTIVITY_CLASSNAME, "com.tencent.shadow.sample.plugin.app.lib.gallery.splash.SplashActivity");
        // ...
        startActivity(intent);
    }
});


可以看到,这里是通过PluginLoadActivity来启动的,传入了要启动的插件Activity:SplashActivity,接着就到PluginLoadActivity里看一下具体的启动。



class PluginLoadActivity extends Activity {
    public void startPlugin() {
        PluginHelper.getInstance().singlePool.execute(new Runnable() {
            @Override
            public void run() {
                HostApplication.getApp().loadPluginManager(PluginHelper.getInstance().pluginManagerFile);
                // ...
                bundle.putString(Constant.KEY_ACTIVITY_CLASSNAME, getIntent().getStringExtra(Constant.KEY_ACTIVITY_CLASSNAME));
                HostApplication.getApp().getPluginManager()
                        .enter(PluginLoadActivity.this, Constant.FROM_ID_START_ACTIVITY, bundle, new EnterCallback() {
                            @Override
                            public void onShowLoadingView(final View view) {
                                // 设置加载的样式
                                mHandler.post(new Runnable() {
                                    @Override
                                    public void run() {
                                        mViewGroup.addView(view);
                                    }
                                });
                            }
                            // ...
                        });
            }
        });
    }
}


这里可以看到,是通过HostApplication获取到PluginManager,然后调用其enter方法,进入插件。这里先看看返回的PluginManager是什么。



class HostApplication extends Application {
    public void loadPluginManager(File apk) {
        if (mPluginManager == null) {
            // 创建 PluginManager
            mPluginManager = Shadow.getPluginManager(apk);
        }
    }

    public PluginManager getPluginManager() {
        return mPluginManager;
    }
}

public class Shadow {
    public static PluginManager getPluginManager(File apk){
        final FixedPathPmUpdater fixedPathPmUpdater = new FixedPathPmUpdater(apk);
        File tempPm = fixedPathPmUpdater.getLatest();
        if (tempPm != null) {
            // 创建 DynamicPluginManager
            return new DynamicPluginManager(fixedPathPmUpdater);
        }
        return null;
    }
}


可以看到,HostApplication里返回的其实是一个DynamicPluginManager实例,那么接下来就要看DynamicPluginManager的enter方法。



class DynamicPluginManager implements PluginManager {
    @Override
    public void enter(Context context, long fromId, Bundle bundle, EnterCallback callback) {
        // 加载 mManagerImpl 实现,这里涉及到了框架的自身动态化,在后面会讲到,这里只要知道,mManagerImpl 最终是 SamplePluginManager 实例即可
        updateManagerImpl(context);
        // mManagerImpl 是 SamplePluginManager 实例,调用其实现
        mManagerImpl.enter(context, fromId, bundle, callback);
        mUpdater.update();
    }
}


通过上面的代码我们知道了,调用DynamicPluginManager.enter会转发到SamplePluginManager.enter中去,接着就看看这个实现。



class SamplePluginManager extends FastPluginManager {
    public void enter(final Context context, long fromId, Bundle bundle, final EnterCallback callback) {
        // ...
        // 启动 Activity
        onStartActivity(context, bundle, callback);
        // ...
    }

    private void onStartActivity(final Context context, Bundle bundle, final EnterCallback callback) {
        // ...
        final String className = bundle.getString(Constant.KEY_ACTIVITY_CLASSNAME);
        // ...
        final Bundle extras = bundle.getBundle(Constant.KEY_EXTRAS);
        if (callback != null) {
            // 创建 loading view
            final View view = LayoutInflater.from(mCurrentContext).inflate(R.layout.activity_load_plugin, null);
            callback.onShowLoadingView(view);
        }
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                // ...
                // 加载插件
                InstalledPlugin installedPlugin = installPlugin(pluginZipPath, nulltrue);
                // 创建插件 Intent
                Intent pluginIntent = new Intent();
                pluginIntent.setClassName(
                        context.getPackageName(),
                        className
                );
                if (extras != null) {
                    pluginIntent.replaceExtras(extras);
                }
                // 启动插件 Activity
                startPluginActivity(context, installedPlugin, partKey, pluginIntent);
                // ...
            }
        });
    }
}


在SamplePluginManager.enter中,调用onStartActivity启动插件Activity,其中开线程去加载插件,然后调用 startPluginActivity。startPluginActivity 实现在其父类FastPluginManager里。



class FastPluginManager {
    public void startPluginActivity(Context context, InstalledPlugin installedPlugin, String partKey, Intent pluginIntent) throws RemoteException, TimeoutException, FailedException {
        Intent intent = convertActivityIntent(installedPlugin, partKey, pluginIntent);
        if (!(context instanceof Activity)) {
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        }
        context.startActivity(intent);
    }
}


其中的重点是convertActivityIntent,将插件intent转化成宿主的intent,然后调用 系统的context.startActivity启动插件。这里的context是PluginLoadActivity.this,从其enter方法中一直传进来的。下面重点看看convertActivityIntent的实现。



class FastPluginManager {
    public Intent convertActivityIntent(InstalledPlugin installedPlugin, String partKey, Intent pluginIntent) throws RemoteException, TimeoutException, FailedException {
        // 创建 mPluginLoader
        loadPlugin(installedPlugin.UUID, partKey);
        // 先调用 Application onCreate 方法
        mPluginLoader.callApplicationOnCreate(partKey);
        // 转化插件 intent 为 代理 Activity intent
        return mPluginLoader.convertActivityIntent(pluginIntent);
    }
}


到了这里其实有一些复杂了,因为mPluginLoader是通过Binder去调用相关方法的。由于这里涉及到了Binder的使用,需要读者了解Binder相关的知识,代码比较繁琐,这里就不具体分析代码实现了,用一张图理顺一下对应的关系:


腾讯新开源的插件化框架 Shadow,原来是这么玩的


通过上面的Binder对应图,我们可以简单的理解为,调用mPluginLoader中的方法,就是调用DynamicPluginLoader中的方法,调用mPpsController的方法,就是调用PluginProcessService中的方法。所以这里的mPluginLoader.convertActivityIntent相当于调用了DynamicPluginLoader.convertActivityIntent。



internal class DynamicPluginLoader(hostContext: Context, uuid: String) {
    fun convertActivityIntent(pluginActivityIntent: Intent): Intent? {
        return mPluginLoader.mComponentManager.convertPluginActivityIntent(pluginActivityIntent)
    }
}


调用到了ComponentManager.convertPluginActivityIntent方法。



abstract class ComponentManager : PluginComponentLauncher {
    override fun convertPluginActivityIntent(pluginIntent: Intent): Intent {
        return if (pluginIntent.isPluginComponent()) {
            pluginIntent.toActivityContainerIntent()
        } else {
            pluginIntent
        }
    }

    private fun Intent.toActivityContainerIntent(): Intent {
        // ...
        return toContainerIntent(bundleForPluginLoader)
    }

    private fun Intent.toContainerIntent(bundleForPluginLoader: Bundle): Intent {
        val className = component.className!!
        val packageName = packageNameMap[className]!!
        component = ComponentName(packageName, className)
        val containerComponent = componentMap[component]!!
        val businessName = pluginInfoMap[component]!!.businessName
        val partKey = pluginInfoMap[component]!!.partKey

        val pluginExtras: Bundle? = extras
        replaceExtras(null as Bundle?)

        val containerIntent 
= Intent(this)
        containerIntent.component = containerComponent

        bundleForPluginLoader.putString(CM_CLASS_NAME_KEY, className)
        bundleForPluginLoader.putString(CM_PACKAGE_NAME_KEY, packageName)

        containerIntent.putExtra(CM_EXTRAS_BUNDLE_KEY, pluginExtras)
        containerIntent.putExtra(CM_BUSINESS_NAME_KEY, businessName)
        containerIntent.putExtra(CM_PART_KEY, partKey)
        containerIntent.putExtra(CM_LOADER_BUNDLE_KEY, bundleForPluginLoader)
        containerIntent.putExtra(LOADER_VERSION_KEY, BuildConfig.VERSION_NAME)
        containerIntent.putExtra(PROCESS_ID_KEY, DelegateProviderHolder.sCustomPid)
        return containerIntent
    }
}


这里最终调用到toContainerIntent方法,终于水落石出了。在toContainerIntent中,创建了新的宿主代理Activity的 intent,这里的containerComponent对应的就是前面在Manifest里注册的PluginDefaultProxyActivity,返回代理activity intent 以后,调用context.startActivity(intent)就启动了代理Activity。


PluginDefaultProxyActivity继承自PluginContainerActivity,这个也就是整个框架的代理Activity,在PluginContainerActivity里,就是常规的分发生命周期了。和之前在插件化原理里介绍的差不多了。中间通过HostActivityDelegate分发生命周期。



class ShadowActivityDelegate(private val mDIDI) : HostActivityDelegateShadowDelegate() {
    // ...
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
        // 设置 application,resources 等等
        mDI.inject(this, partKey)
        // 创建插件资源
        mMixResources = MixResources(mHostActivityDelegator.superGetResources(), mPluginResources)
        // 设置插件主题
        mHostActivityDelegator.setTheme(pluginActivityInfo.themeResource)
        try {
            val aClass = mPluginClassLoader.loadClass(pluginActivityClassName)
            // 创建插件 activity
            val pluginActivity = PluginActivity::class.java.cast(aClass.newInstance())
            // 初始化插件 activity
            initPluginActivity(pluginActivity)
            mPluginActivity 
= pluginActivity
            //设置插件AndroidManifest.xml 中注册的WindowSoftInputMode
            mHostActivityDelegator.window.setSoftInputMode(pluginActivityInfo.activityInfo.softInputMode)
            // 获取 savedInstanceState
            val pluginSavedInstanceState: Bundle? = savedInstanceState?.getBundle(PLUGIN_OUT_STATE_KEY)
            pluginSavedInstanceState?.classLoader = mPluginClassLoader
            // 调用插件 activity onCreate 
            pluginActivity.onCreate(pluginSavedInstanceState)
            mPluginActivityCreated = true
        } catch (e: Exception) {
            throw RuntimeException(e)
        }
    }

    // 获取插件资源
    override fun getResources(): Resources {
        if (mDependenciesInjected) {
            return mMixResources;
        } else {
            return Resources.getSystem()
        }
    }
}


上面就是在宿主中启动插件Activity的整个流程,下面看看在插件中如何启动Activity的。


插件中如何启动插件Activity


插件中启动Activity原理如下图:


腾讯新开源的插件化框架 Shadow,原来是这么玩的


我们上面说到过,插件Activity会在打包过程中替换其父类为ShadowActivity,很明显了,在插件中启动Activity即调用startActivity,自然就是调用ShadowActivity的startActivity了。startActivity在其父类ShadowContext里实现,我们来具体看下。



class ShadowContext extends SubDirContextThemeWrapper {
    public void startActivity(Intent intent) {
        final Intent pluginIntent = new Intent(intent);
        // ...
        final boolean success = mPluginComponentLauncher.startActivity(this, pluginIntent);
        // ...
    }
}


可以看到,是通过mPluginComponentLauncher.startActivity继续调用的,mPluginComponentLauncher就是ComponentManager的一个实例,是在前面说到的初始化插件Activity的时候设置的。内部实现就比较简单了。



abstract class ComponentManager : PluginComponentLauncher {
    override fun startActivity(shadowContext: ShadowContext, pluginIntent: Intent): Boolean {
        return if (pluginIntent.isPluginComponent()) {
            shadowContext.superStartActivity(pluginIntent.toActivityContainerIntent())
            true
        } else {
            false
        }
    }
}

public class ShadowContext extends SubDirContextThemeWrapper {
    public void superStartActivity(Intent intent) {
        // 调用系统 startActivity
        super.startActivity(intent);
    }
}


通过调用toActivityContainerIntent转化intent为代理Activity的intent,然后调用系统startActivity启动代理Activity,剩下的步骤就和上面宿主启动插件Activity中讲到的一样了。到现在,我们就对框架中Activity的启动基本了解了。


/   Service实现   /


Service的实现,我们直接看 插件中如何启动的即可。看一下ShadowContext中的startService实现:



public class ShadowContext extends SubDirContextThemeWrapper {
    public ComponentName startService(Intent service) {
        if (service.getComponent() == null) {
            return super.startService(service);
        }
        Pair<Boolean, ComponentName> ret = mPluginComponentLauncher.startService(this, service);
        if (!ret.first)
            return super.startService(service);
        return ret.second;
    }
}


也是调用mPluginComponentLauncher.startService,这里我们就比较熟悉了,就是ComponentManager.startService。



abstract class ComponentManager : PluginComponentLauncher {
    override fun startService(context: ShadowContext, service: Intent): Pair<Boolean, ComponentName> {
        if (service.isPluginComponent()) {
            // 插件service intent不需要转换成container service intent,直接使用intent
            val component = mPluginServiceManager!!.startPluginService(service)
            // ...
        }
        return Pair(false, service.component)
    }
}


这里直接调用PluginServiceManager.startPluginService。



class PluginServiceManager(private val mPluginLoaderShadowPluginLoaderprivate val mHostContextContext{
    fun startPluginService(intent: Intent): ComponentName? {
        val componentName = intent.component
        // 检查所请求的service是否已经存在
        if (!mAliveServicesMap.containsKey(componentName)) {
            // 创建 Service 实例并调用 onCreate 方法
            val service = createServiceAndCallOnCreate(intent)
            mAliveServicesMap[componentName] = service
            // 通过startService启动集合
            mServiceStartByStartServiceSet.add(componentName)
        }
        mAliveServicesMap[componentName]?.onStartCommand(intent, 0, getNewStartId())
        return componentName
    }

    private fun createServiceAndCallOnCreate(intent: Intent): ShadowService {
        val service = newServiceInstance(intent.component)
        service.onCreate()
        return service
    }
}


可以看到,在Shadow中对Service的处理很简单,直接调用其生命周期方法,不过如此的实现方式,可能会带来一些时序问题。


/   BroadcastReceiver实现   /


广播的实现也比较常规,在插件中动态注册和发送广播,直接调用系统的方法即可,因为广播不涉及生命周期等复杂的内容。需要处理的就是在Manifest中静态注册的广播。这个理论上也和我们之前讲解插件化原理时候实现基本一致,解析Manifest,然后进行动态注册。不过在Shadow的demo里,并没有做解析,就是直接写在了代码里。



// AndroidManifest.xml
        <receiver android:name="com.tencent.shadow.sample.plugin.app.lib.usecases.receiver.MyReceiver">
            <intent-filter>
                <action android:name="com.tencent.test.action" />
            </intent-filter>
        </receiver>

// SampleComponentManager
public class SampleComponentManager extends ComponentManager {
    public List<BroadcastInfo> getBroadcastInfoList(String partKey) {
        List<ComponentManager.BroadcastInfo> broadcastInfos = new ArrayList<>();
        if (partKey.equals(Constant.PART_KEY_PLUGIN_MAIN_APP)) {
            broadcastInfos.add(
                    new ComponentManager.BroadcastInfo(
                            "com.tencent.shadow.sample.plugin.app.lib.usecases.receiver.MyReceiver",
                            new String[]{"com.tencent.test.action"}
                    )
            );
        }
        return broadcastInfos;
    }
}


/   ContentProvider 实现   /


关于ContentProvider的实现,其实和之前插件化原理文章中思路是一致的,也是通过注册代理ContentProvider然后分发给插件Provider,这里就不多做介绍了。


/   框架自身动态化   /


Shadow框架还有一个特点,就是框架本身也实现了动态化,这里的实现主要是三步:


  1. 抽象接口类

  2. 在插件中实现工厂类

  3. 通过工厂类动态创建接口的实现


我们以PluginLoaderImpl为例来看看,在前面介绍Activity启动流程的时候,有说到mPluginLoader.convertActivityIntent用来转换插件intent为代理Activity的 intent,这里的mPluginLoader就是动态创建的。我们来看一下创建过程。创建入口在PluginProcessService.loadPluginLoader里。



public class PluginProcessService extends Service {
    void loadPluginLoader(String uuid) throws FailedException {
        // ...
        PluginLoaderImpl pluginLoader = new LoaderImplLoader().load(installedApk, uuid, getApplicationContext());
        // ...
    }
}


接下来需要看一下LoaderImplLoader的具体实现。



final class LoaderImplLoader extends ImplLoader {
    // 创建 PluginLoaderImpl 的工厂类
    private final static String sLoaderFactoryImplClassName
            = "com.tencent.shadow.dynamic.loader.impl.LoaderFactoryImpl";

    // 动态创建 PluginLoaderImpl
    PluginLoaderImpl load(InstalledApk installedApk, String uuid, Context appContext) throws Exception {
        // 创建插件 ClassLoader
        ApkClassLoader pluginLoaderClassLoader = new ApkClassLoader(
                installedApk,
                LoaderImplLoader.class.getClassLoader(),
                loadWhiteList(installedApk),
                1
        );
        // 获取插件中的 工厂类
        LoaderFactory loaderFactory = pluginLoaderClassLoader.getInterface(
                LoaderFactory.class,
                sLoaderFactoryImplClassName
        );
        // 调用工厂类方法创建 PluginLoaderImpl 实例
        return loaderFactory.buildLoader(uuid, appContext);
    }
}


从上面的代码和注释来看,其实很简单,创建插件的ClassLoader,通过ClassLoader创建一个工厂类的实例,然后调用工厂类方法生成 PluginLoaderImpl。而工厂类和PluginLoaderImpl的实现都在插件中,就达到了框架自身的动态化。PluginManagerImpl也是一样的道理,在DynamicPluginManager.updateManagerImpl中通过ManagerImplLoader.load加载。


/   总结   /


腾讯新开源的插件化框架 Shadow,原来是这么玩的


其实整个框架看下来,没有什么黑科技,就是代理Activity的原理加上设计模式的运用。其实目前几大插件化框架,基本上都是hook系统为主,像使用代理Activity原理的,Shadow应该算是第一个各方面实现都比较完整的框架,带来的好处就是不用去调用系统限制的api,更加稳定。在系统控制越来越严格的趋势下,也算是一个比较好的选择。原理简单,其中的设计思想可以学习~


推荐阅读:

总是听到有人说AndroidX,到底什么是AndroidX?

看一看Facebook工程师是怎么评价《第一行代码》的

给你的Android应用穿件花衣服吧!


欢迎关注我的公众号

学习技术或投稿


腾讯新开源的插件化框架 Shadow,原来是这么玩的


腾讯新开源的插件化框架 Shadow,原来是这么玩的

长按上图,识别图中二维码即可关注


本文地址:https://blog.csdn.net/c10WTiybQ1Ye3/article/details/100135318