Android 复习笔记 —— 扒一扒 Context

简介: Android 复习笔记 —— 扒一扒 Context

目录


  • 什么是 Context?
  • 四大组件和 Context
  • Application 和 Context
  • 为什么 Application 的 Context 不可以创建 Dialog ?
  • 未完待遇...


文章开头,先来看一段代码:

public class ContextActivity extends AppCompatActivity {
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_context);
        Log.e("context", "getApplication in Activity: " + getApplication().getClass().getName());
        Log.e("context", "getApplicationContext in Activity: " + getApplicationContext().getClass().getName());
        Log.e("context", "getBaseContext in Activity: " + getBaseContext().getClass().getName());
        startService(new Intent(this,ContextService.class));
    }
}
复制代码


你能准确的说出这三行打印语句的执行结果吗?如果不能,你需要认真阅读这篇文章。


什么是 Context ?


Context 是一个抽象类。既然是抽象类,那么它就代表了一类具体对象的通用特征。先来看一下 Context 的类图:

image.png

其中看到了我们很熟悉的 ActivityServiceApplication,这些都是 Context 的具体实现类,也就是说 Context 抽象了这些类的通用特征和功能:

  • 获取系统资源,getResources()getAssets()
  • 启动各种系统组件
  • 获取系统服务
  • ......


这些与系统环境息息相关的功能都是由 Context 提供的,所以一般将其称为 上下文,它其实就是对当前运行环境的具体描述,为系统组件的正常运行提供必要的环境和资源。


在上面的类图中,可能有两个读者比较陌生的类,ContextWraaperContextImpl

ContextImpl 很好理解,它就是 Context 的具体实现类。Context 类中的所有抽象方法都是在 ContextImpl 中实现的。


class ContextImpl extends Context {
    ......
   @Override
    public AssetManager getAssets() {
        return getResources().getAssets();
    }
    @Override
    public Resources getResources() {
        return mResources;
    }
    @Override
    public PackageManager getPackageManager() {
        if (mPackageManager != null) {
            return mPackageManager;
        }
        IPackageManager pm = ActivityThread.getPackageManager();
        if (pm != null) {
            // Doesn't matter if we make more than one instance.
            return (mPackageManager = new ApplicationPackageManager(this, pm));
        }
        return null;
    }
    @Override
    public ContentResolver getContentResolver() {
        return mContentResolver;
    }
    @Override
    public Looper getMainLooper() {
        return mMainThread.getLooper();
    }
    ......
}
复制代码


ContextWraaper 其实也很简单,直接看它的实现代码:

public class ContextWrapper extends Context {
    @UnsupportedAppUsage
    Context mBase;
    public ContextWrapper(Context base) {
        mBase = base;
    }
    /**
     * 在这个方法中给 mBase 赋值
     */
    protected void attachBaseContext(Context base) {
        if (mBase != null) {
            throw new IllegalStateException("Base context already set");
        }
        mBase = base;
    }
    public Context getBaseContext() {
        return mBase;
    }
    @Override
    public AssetManager getAssets() {
        return mBase.getAssets();
    }
    @Override
    public Resources getResources() {
        return mBase.getResources();
    }
    ......
}
复制代码


这是一个典型的 装饰者模式,也叫做 修饰模式,一下来自维基百科:

修饰模式,是面向对象编程领域中,一种动态地往一个类中添加新的行为的设计模式。就功能而言,修饰模式相比生成子类更为灵活,这样可以给某个对象而不是整个类添加一些功能。

通过使用修饰模式,可以在运行时扩充一个类的功能。原理是:增加一个修饰类包裹原来的类,包裹的方式一般是通过在将原来的对象作为修饰类的构造函数的参数。装饰类实现新的功能,但是,在不需要用到新功能的地方,它可以直接调用原来的类中的方法。修饰类必须和原来的类有相同的接口。

修饰模式是类继承的另外一种选择。类继承在编译时候增加行为,而装饰模式是在运行时增加行为。

当有几个相互独立的功能需要扩充时,这个区别就变得很重要。在有些面向对象的编程语言中,类不能在运行时被创建,通常在设计的时候也不能预测到有哪几种功能组合。这就意味着要为每一种组合创建一个新类。相反,修饰模式是面向运行时候的对象实例的,这样就可以在运行时根据需要进行组合。一个修饰模式的示例是JAVA里的Java I/O Streams的实现。


Context 是基本的抽象类,无论是实现类,还是装饰类,都直接或间接的实现它。ContextImpl 是 Context 的直接实现类,但各个组件并不是直接继承 ContextImpl,而是通过装饰类 ContextWrapper 来持有 ContextImpl。这是为什么呢?对于 Activity 和 Service 来说,它们都需要系统上下文运行环境,但它们又是不同的。Activity 需要显示到前台,它有页面,它需要主题,于是有了继承自 ContextWrapper 的 ContextThemeWrapper,扩展了功能,给 Activity 提供了主题。同时,Activity、Service、Application 这些具体组件本身又扩展出了不同的生命周期功能。


所以,装饰器模式通过组合和扩展装饰类,来给不同的具体对象提供了不同的功能扩展。

ActivityServiceApplication 最终都是继承自装饰类 ContextWrapperContextWrapper 通过 attachBaseContext() 方法来获取实际做事的 ContextImpl 对象。 所以这些组件的创建过程中,一定会在某一时机调用 attachBaseContext() 方法对 mBase 对象进行赋值,让我们从源码里面找找答案。


四大组件和 Context


Activity 和 Context

先说 Activity,Activity 的启动过程极其复杂,我们就直接从 ActivityThreadperformLaunchActivity() 方法看起。

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
        ActivityInfo aInfo = r.activityInfo;
        if (r.packageInfo == null) {
            // 1. 获取 LoadedApk 对象
            r.packageInfo = getPackageInfo(aInfo.applicationInfo, r.compatInfo,
                    Context.CONTEXT_INCLUDE_CODE);
        }
        ......
        // 2. 创建 ContextImpl 对象
        ContextImpl appContext = createBaseContextForActivity(r);
        Activity activity = null;
        try {
            java.lang.ClassLoader cl = appContext.getClassLoader();
            // 3. 反射创建 Activity 对象
            activity = mInstrumentation.newActivity(
                    cl, component.getClassName(), r.intent);
            ......
        } catch (Exception e) {
            ......
        }
        try {
            // 4. 创建 Application 对象
            Application app = r.packageInfo.makeApplication(false, mInstrumentation);
            if (activity != null) {
                ......
                appContext.setOuterContext(activity);
        // 5. 绑定 activity
                activity.attach(appContext, this, getInstrumentation(), r.token,
                        r.ident, app, r.intent, r.activityInfo, title, r.parent,
                        r.embeddedID, r.lastNonConfigurationInstances, config,
                        r.referrer, r.voiceInteractor, window, r.configCallback);
                ......
                int theme = r.activityInfo.getThemeResource();
                if (theme != 0) {
                    // 设置主题
                    activity.setTheme(theme);
                }
                // 6. 回调 onCreate()
                if (r.isPersistable()) {
                    mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
                } else {
                    mInstrumentation.callActivityOnCreate(activity, r.state);
                }
                ......
                r.activity = activity;
            }
            r.setState(ON_CREATE);
            mActivities.put(r.token, r);
        } catch (SuperNotCalledException e) {
            throw e;
        } catch (Exception e) {
           ......
        }
        return activity;
    }
复制代码


整理一下大致的执行流程:

  1. 获取 LoadedApk 对象,表示加载过的 Apk ,通常一个 App 对应着一个 LoadedApk
  2. 通过 createBaseContextForActivity() 方法创建 ContextImpl 对象
  3. 反射创建 Activity 对象
  4. 创建 Application 对象,这里也是用的反射。如果开发者没有声明自己的 Application 的话,就是默认的 androoid.app.Application
  5. 调用 activity.attach() ,这个方法很重要,后面详细说
  6. 回调 onCreate()


接着就是 Activity 正常的生命周期流程了。

重点看一下 createBaseContextForActivity() 方法和 attach() 方法。

private ContextImpl createBaseContextForActivity(ActivityClientRecord r) {
        ContextImpl appContext = ContextImpl.createActivityContext(
                this, r.packageInfo, r.activityInfo, r.token, displayId, r.overrideConfig);
        ......
        return appContext;
    }
复制代码


调用了 ContextImpl.createActivityContext() 方法。

static ContextImpl createActivityContext(ActivityThread mainThread,
            LoadedApk packageInfo, ActivityInfo activityInfo, IBinder activityToken, int displayId,
            Configuration overrideConfiguration) {
        ......
        // 创建 ContextImpl 对象
        ContextImpl context = new ContextImpl(null, mainThread, packageInfo, activityInfo.splitName,
                activityToken, null, 0, classLoader);
        ......
        final ResourcesManager resourcesManager = ResourcesManager.getInstance();
        context.setResources(resourcesManager.createBaseActivityResources(activityToken,
                packageInfo.getResDir(),
                splitDirs,
                packageInfo.getOverlayDirs(),
                packageInfo.getApplicationInfo().sharedLibraryFiles,
                displayId,
                overrideConfiguration,
                compatInfo,
                classLoader));
        context.mDisplay = resourcesManager.getAdjustedDisplay(displayId,
                context.getResources());
        return context;
    }
复制代码


装饰类 ContextWrapper 真正需要的 ContextImpl 对象现在已经创建出来了,但是还没有绑定到 Activity 。继续看 Activity.attach() 方法,注意 attach() 方法的第一个参数就是刚刚创建出来的 ContextImpl 对象。


final void attach(Context context, ActivityThread aThread,
            Instrumentation instr, IBinder token, int ident,
            Application application, Intent intent, ActivityInfo info,
            CharSequence title, Activity parent, String id,
            NonConfigurationInstances lastNonConfigurationInstances,
            Configuration config, String referrer, IVoiceInteractor voiceInteractor,
            Window window, ActivityConfigCallback activityConfigCallback) {
        // 回调 attachBaseContext()
        attachBaseContext(context);
        ......
    // 创建 PhoneWindow
        mWindow = new PhoneWindow(this, window, activityConfigCallback);
        ......    
        mWindow.setWindowManager(
                (WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
                mToken, mComponent.flattenToString(),
                (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
        ......
    }
复制代码


你对 attachBaseContext() 方法应该还有印象。ContextWrapper 正是通过这个方法给 mBase 对象赋值,拿到真正的 ContextImpl 对象。到这里,整个逻辑就通顺了。

注意 attach() 方法中的 setWindowManager() 方法中的 mToken 参数,这决定了 Application Context 无法创建和显示 Dialog 。后续会进行详细分析。


再回头看看文章开头的问题。

Log.e("context", "getApplication in Activity: " + getApplication().getClass().getName());
Log.e("context", "getApplicationContext in Activity: " + getApplicationContext().getClass().getName());
Log.e("context", "getBaseContext in Activity: " + getBaseContext().getClass().getName());
复制代码


第一个 getApplication() ,看下源码就知道了:

public final Application getApplication() {
    return mApplication;
}
复制代码


getApplication() 返回的是当前的 Application 对象。开发者没有声明自己实现的 Application 的话,就是系统默认的 android.app.Application


第二个 getApplicationContext(),它并不是 Activity 中的方法,而是 ContextWrapper 的。直接看源码:

@Override
public Context getApplicationContext() {
    return mBase.getApplicationContext();
}
复制代码


调用的是 ContextImpl.getApplicationContext()

@Override
public Context getApplicationContext() {
    return (mPackageInfo != null) ?
        mPackageInfo.getApplication() : mMainThread.getApplication();
}
复制代码


所以返回的同样是 Application 对象。

第三个,getBaseContext() ,同样是 ContextWrapper 中的方法:

public Context getBaseContext() {
    return mBase;
}
复制代码


所以这里返回的是 ContextImpl 对象。

最后的打印语句是:

E/context: getApplication in Activity: luyao.android.App
E/context: getApplicationContext in Activity: luyao.android.App
E/context: getBaseContext in Activity: android.app.ContextImpl
复制代码


关于 Activity 就说这么多了。下面来看看 Service 。


Service 和 Context

Service 其实和 Activity 的整体流程基本一致,创建服务的主要逻辑在 ActivityThread.handleCreateService() 方法中。这里我就不贴源码了,简单叙述一下:

  1. 创建 LoadedApk 对象
  2. 反射创建 Service 对象
  3. 调用 ContextImpl.createAppCntext() 创建 ContextImpl 对象
  4. 创建 Application 对象
  5. 调用 service.attach() 进行绑定
  6. 回调 service 的 onCreate() 方法


直接看一下 Service.attach() 方法:

public final void attach(
            Context context,
            ActivityThread thread, String className, IBinder token,
            Application application, Object activityManager) {
        attachBaseContext(context);
        ......
    }
复制代码


又看到了熟悉的 attachBaseContext() 方法。

ActivityService 都是继承自 ContextWrapper 的,最后都是通过 attachBaseContext() 对 ContextImpl 类型的 mBase 赋值。而 ContentProviderBroadcastReceiver 都没有继承 Context,所以它们获取 Context 的方式会有一点不一样。


ContentProvider 和 Context

先来看 ContentProvider,创建 Provider 的逻辑在 Activity.installProvider() 方法中:

private ContentProviderHolder installProvider(Context context,
            ContentProviderHolder holder, ProviderInfo info,
            boolean noisy, boolean noReleaseNeeded, boolean stable) {
        ContentProvider localProvider = null;
        IContentProvider provider;
      // 创建 LoadedApk 和 ContextImpl
        c = context.createPackageContext(ai.packageName,Context.CONTEXT_INCLUDE_CODE);
        try {
            ......
      // 创建 ContentProvider
            localProvider = packageInfo.getAppFactory()
                    .instantiateProvider(cl, info.name);
            provider = localProvider.getIContentProvider();
            ......
            // 绑定 Context
            localProvider.attachInfo(c, info);
        } catch (java.lang.Exception e) {
            ......
        }
        ......
        return retHolder;
}
复制代码


最后在 ContentProvider.attachInfo() 方法中进行了 ContextImpl 的赋值操作。

private void attachInfo(Context context, ProviderInfo info, boolean testing) {
        if (mContext == null) {
            // 给 mContext 赋值
            mContext = context;
            ......
            // 回调 onCreate()
            ContentProvider.this.onCreate();
        }
    }
复制代码

这样 ContentProvider 也能拿到 Context 对象了。


BroadcastReceiver 和 Context

最后就是 BroadcastReceiver 了,对应 ActivityThread.handleReceiver() 方法:

private void handleReceiver(ReceiverData data) {
        ......
    // 创建 LoadedApk 对象
        LoadedApk packageInfo = getPackageInfoNoCheck(
                data.info.applicationInfo, data.compatInfo);
        Application app;
        BroadcastReceiver receiver;
        ContextImpl context;
        try {
      // 创建 Application 对象
            app = packageInfo.makeApplication(false, mInstrumentation);
      // 创建 ContextImpl 对象
            context = (ContextImpl) app.getBaseContext();
            ......
      // 创建 BroadcastReceiver 对象
            receiver = packageInfo.getAppFactory()
                    .instantiateReceiver(cl, data.info.name, data.intent);
        } catch (Exception e) {
            ......
        }
        try {
            ......
            // 回调 onReceive()
            receiver.onReceive(context.getReceiverRestrictedContext(),
                    data.intent);
        } catch (Exception e) {
          ......
        } finally {
            sCurrentBroadcastIntent.set(null);
        }
        ......
    }
复制代码


大多数步骤和 Activity 还是类似的,只是到最后回调 onReceive() 方法的时候,才会把 ContextImpl 对象传过去。注意,这里并不是直接返回原生的 ContextImpl 对象,而是调用 context.getReceiverRestrictedContext() 返回一个 受限制ReceiverRestrictedContext,你无法使用这个 Context 对象启动 Service 。


这不正是 装饰者模式 的体现?想给广播的 Context 对象加点限制,那就再来一个装饰类 ReceiverRestrictedContext ,它继承了 ContextWrapper , 重写部分方法以限制应用场景。通过增加和组合装饰类,而不是增加子类,来实现功能扩展。


Application 和 Context



四大组件说完了,别忘了 Application 也是 Context 的间接子类。

Application 的创建时机得从应用进程的创建开始说起。Zygote 进程在接收到客户端请求创建应用进程的 socket 请求之后,会 fork 出子进程,并反射调用 ActivityThread 的静态 main() 方法。接着是 AMS 和客户端的一系列 Binder 调用以及 Handler 通信,最终主线程在收到 BIND_APPLICATION 消息之后回调 handleBindApplication() 方法,到这里就是我们需要的逻辑了:

private void handleBindApplication(AppBindData data){
    ......
    // 获取 ContextImpl
    final ContextImpl appContext = ContextImpl.createAppContext(this, data.info);
    ......
    // 创建 Application 对象
    app = data.info.makeApplication(data.restrictedBackupMode, null);
    ......
    // 调用 Application 的 onCreate() 方法
    mInstrumentation.callApplicationOnCreate(app);
}
复制代码


你可能会疑惑怎么没有回调 attBaseContext() 方法,别急,看看 LoadedApk.makeApplication() 方法是如何创建 Application 的。

public Application makeApplication(boolean forceDefaultAppClass,
            Instrumentation instrumentation) {
    ......
    // 创建 ContextImpl
    ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
  // 反射创建 Application
    app = mActivityThread.mInstrumentation.newApplication(
            cl, appClass, appContext);
    appContext.setOuterContext(app);
}
复制代码


通过 Instrumentation.newApplication() 方法创建 Application 。

public Application newApplication(ClassLoader cl, String className, Context context)
            throws InstantiationException, IllegalAccessException, 
            ClassNotFoundException {
        // 反射创建
        Application app = getFactory(context.getPackageName())
                .instantiateApplication(cl, className);
        // 重点
        app.attach(context);
        return app;
    }
复制代码


重点就在 Application.attach() 方法。

final void attach(Context context) {
    attachBaseContext(context);
    mLoadedApk = ContextImpl.getImpl(context).mPackageInfo;
}
复制代码


在这里调用了 attachBaseContext() 方法进行赋值,也验证了 attachBaseContext() 的确比 onCreate() 先调用。


为什么 Application 的 Context 不可以创建 Dialog ?



使用 Application 的 Context 创建 Dialog 并显示,会报如下错误:

Caused by: android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
    at android.view.ViewRootImpl.setView(ViewRootImpl.java:951)
    at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:387)
    at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:96)
    at android.app.Dialog.show(Dialog.java:344)
复制代码


注意错误信息 token null is not valid ,还记得文章前面说到 Activity 和 Context 的时候,有这么一段代码:

mWindow.setWindowManager(
                (WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
                mToken, mComponent.flattenToString(),
                (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
复制代码


注意其中的 mToken 参数不为 null ,是不是就说明了 Application 的 token 参数为空呢?

本来准备接着说说这个问题,但可能造成文章篇幅过长,所以 Android 复习笔记 下一篇会单独来唠唠这个问题。



相关文章
|
6月前
|
Linux 编译器 Android开发
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
在Linux环境下,本文指导如何交叉编译x265的so库以适应Android。首先,需安装cmake和下载android-ndk-r21e。接着,下载x265源码,修改crosscompile.cmake的编译器设置。配置x265源码,使用指定的NDK路径,并在配置界面修改相关选项。随后,修改编译规则,编译并安装x265,调整pc描述文件并更新PKG_CONFIG_PATH。最后,修改FFmpeg配置脚本启用x265支持,编译安装FFmpeg,将生成的so文件导入Android工程,调整gradle配置以确保顺利运行。
225 1
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
|
6月前
|
Unix Linux Shell
FFmpeg开发笔记(八)Linux交叉编译Android的FFmpeg库
在Linux环境下交叉编译Android所需的FFmpeg so库,首先下载`android-ndk-r21e`,然后解压。接着,上传FFmpeg及相关库(如x264、freetype、lame)源码,修改相关sh文件,将`SYSTEM=windows-x86_64`改为`SYSTEM=linux-x86_64`并删除回车符。对x264的configure文件进行修改,然后编译x264。同样编译其他第三方库。设置环境变量`PKG_CONFIG_PATH`,最后在FFmpeg源码目录执行配置、编译和安装命令,生成的so文件复制到App工程指定目录。
334 9
FFmpeg开发笔记(八)Linux交叉编译Android的FFmpeg库
|
1月前
|
Web App开发 安全 程序员
FFmpeg开发笔记(五十五)寒冬里的安卓程序员可进阶修炼的几种姿势
多年的互联网寒冬在今年尤为凛冽,坚守安卓开发愈发不易。面对是否转行或学习新技术的迷茫,安卓程序员可从三个方向进阶:1)钻研谷歌新技术,如Kotlin、Flutter、Jetpack等;2)拓展新功能应用,掌握Socket、OpenGL、WebRTC等专业领域技能;3)结合其他行业,如汽车、游戏、安全等,拓宽职业道路。这三个方向各有学习难度和保饭碗指数,助你在安卓开发领域持续成长。
60 1
FFmpeg开发笔记(五十五)寒冬里的安卓程序员可进阶修炼的几种姿势
|
22天前
|
Linux API 开发工具
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
ijkplayer是由B站研发的移动端播放器,基于FFmpeg 3.4,支持Android和iOS。其源码托管于GitHub,截至2024年9月15日,获得了3.24万星标和0.81万分支,尽管已停止更新6年。本文档介绍了如何在Linux环境下编译ijkplayer的so库,以便在较新的开发环境中使用。首先需安装编译工具并调整/tmp分区大小,接着下载并安装Android SDK和NDK,最后下载ijkplayer源码并编译。详细步骤包括环境准备、工具安装及库编译等。更多FFmpeg开发知识可参考相关书籍。
67 0
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
|
3月前
|
JavaScript 前端开发 Java
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向
IT寒冬使APP开发门槛提升,安卓程序员需转型。选项包括:深化Android开发,跟进Google新技术如Kotlin、Jetpack、Flutter及Compose;研究Android底层框架,掌握AOSP;转型Java后端开发,学习Spring Boot等框架;拓展大前端技能,掌握JavaScript、Node.js、Vue.js及特定框架如微信小程序、HarmonyOS;或转向C/C++底层开发,通过音视频项目如FFmpeg积累经验。每条路径都有相应的书籍和技术栈推荐,助你顺利过渡。
59 3
FFmpeg开发笔记(四十七)寒冬下安卓程序员的几个技术转型发展方向
|
3月前
|
编解码 安全 Ubuntu
Android Selinux 问题处理笔记
这篇文章是关于处理Android系统中SELinux权限问题的笔记,介绍了如何通过分析SELinux拒绝的日志、修改SELinux策略文件,并重新编译部署来解决权限问题,同时提供了一些SELinux的背景知识和实用工具。
59 0
|
6月前
|
安全 Linux Android开发
FFmpeg开发笔记(十六)Linux交叉编译Android的OpenSSL库
该文介绍了如何在Linux服务器上交叉编译Android的FFmpeg库以支持HTTPS视频播放。首先,从GitHub下载openssl源码,解压后通过编译脚本`build_openssl.sh`生成64位静态库。接着,更新环境变量加载openssl,并编辑FFmpeg配置脚本`config_ffmpeg_openssl.sh`启用openssl支持。然后,编译安装FFmpeg。最后,将编译好的库文件导入App工程的相应目录,修改视频链接为HTTPS,App即可播放HTTPS在线视频。
107 3
FFmpeg开发笔记(十六)Linux交叉编译Android的OpenSSL库
|
5月前
|
Java API Android开发
技术经验分享:Android源码笔记——Camera系统架构
技术经验分享:Android源码笔记——Camera系统架构
54 0
|
6月前
|
Java 测试技术 开发工具
Android 笔记:AndroidTrain , Lint , build(1),只需一篇文章吃透Android多线程技术
Android 笔记:AndroidTrain , Lint , build(1),只需一篇文章吃透Android多线程技术
|
6月前
|
设计模式 缓存 前端开发
真的强!借助阿里技术博主分享的Android面试笔记,我拿到了字节跳动的offer
真的强!借助阿里技术博主分享的Android面试笔记,我拿到了字节跳动的offer
下一篇
无影云桌面