Android斩首行动——应用层开发Framework必知必会

news2024/11/14 20:42:17

前言

相信做应用层业务开发的同学,都跟我一样,对Framework”深恶痛绝“。确实如此,如果平日里都在做应用层的开发,那么基本上我们很少会去碰Framework的知识。但生活所迫,面试总是逃不过这一关的,所以作为一名合格的打工人,我们还是必须得具备一些Framework的基本知识。

网上有很多文章或浅或深地讲了Framework,有很多源码大家肯定也都看过不止一两次,但过一段时间就忘记了。我这篇文章将会从应用层开发的视角,罗列下我认为需要掌握的Framework基本知识。为了更方便大家去记忆与巩固,我更多的是从流程上进行讲解,而不会对源码进行非常深入的解读,文章会比较长,大家可以当做一个知识小册,根据目录针对性地进行浏览(基于Android 8.0/9.0)。

那话不多说,我们冲!

一、系统启动流程

Zygote进程

Zygote进程是非常重要的一个进程,它负责Android系统中虚拟机(DVM或者ART)的创建、应用进程的创建以及SystemServer进程的创建。

系统在开机后会启动init进程,init进程进而会fork出Zygote进程。Zygote进程在启动时,主要做了这么几件事情:

  1. 创建虚拟机。当Zygote以fork自身的方式去创建应用进程和SystemServer进程时,它们就能拿到虚拟机的一个副本。
  2. 作为Socket服务端监听AMS请求创建进程
  3. 启动SystemServer进程

SystemServer进程

SystemServer进程是我们学习Framework中最重要的一个系统级进程,我们熟知的很多服务(AMS、WMS、PMS等)都属于它提供的一个服务,是与我们APP进程通信最频繁的进程。

SystemServer进程在启动时,主要做了这么几件事情:

  1. 启动Binder线程池,为进程间通信做准备
  2. 启动各种系统服务,比如AMS、WMS、PMS

Launcher进程

Launcher进程实际上就是我们的桌面进程,熟悉它的同学都知道一页桌面本质上就是一个Grid类型的RecylerView,那它是怎么创建的呢?

这就是系统启动的最后一步,当SystemServer进程启动了AMS服务后,AMS会启动Launcher进程。Launcher进程通过与PMS服务通信,获取到机器上所有的安装包信息后,将数据(APP图标、APP名称等)渲染到桌面上。

小结:

系统启动流程可以用下图概括:

二、应用进程启动流程

前面我们讲了SystemServer进程与桌面进程是如何启动的,等它们启动好了之后,就可以从桌面启动我们的应用进程了。

在桌面点击APP图标后,Launcher进程将会向AMS请求创建APP的启动页面。AMS识别到APP进程不存在之后,就会用Socket的方式向Zygote进程请求创建APP进程。Zygote通过fork的方式创建APP进程之后,会反射调用android.app.ActivityThreadmain()方法,初始化ActivityThread。

ActivityThread,可以理解为管理APP主线程任务的一个类。在main()方法中初始化时,我们会初始化主线程的消息循环,从而接收主线程需要处理的所有消息,保证UI在主线程的渲染(消息机制后文会详细介绍)。


public static void main(String[] args) {
    ...
    ActivityThread thread = new ActivityThread();
    thread.attach(false, startSeq);//注释1

    if (sMainThreadHandler == null) {
        sMainThreadHandler = thread.getHandler(); //注释2
    }

    if (false) {
        Looper.myLooper().setMessageLogging(new
                LogPrinter(Log.DEBUG, "ActivityThread"));
    }

    // End of event ActivityThreadMain.
    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
    Looper.loop();
}

private void attach(boolean system, long startSeq) {
    ...
    if (!system) {
        ...
        final IActivityManager mgr = ActivityManager.getService();
        try {
            mgr.attachApplication(mAppThread, startSeq); // 注释3
        } catch (RemoteException ex) {
            throw ex.rethrowFromSystemServer();
        }
        ...
    } else {
        ...
        try {
            mInstrumentation = new Instrumentation();
            mInstrumentation.basicInit(this);
            ContextImpl context = ContextImpl.createAppContext(
                    this, getSystemContext().mPackageInfo);
            mInitialApplication = context.mPackageInfo.makeApplication(true, null);
            mInitialApplication.onCreate();
        } catch (Exception e) {
            throw new RuntimeException(
                    "Unable to instantiate Application():" + e.toString(), e);
        }
    }
    ...
}

可以看到注释2处会创建主线程Handler,这里的Handler类名为H,属于ActivityThread的内部类。后面会多次提到它。

注释1处调用attach方法时,传入的system参数为false,所以是会到注释3处的代码的,这里又回到了AMS,调用AMS的attachApplication方法。attachApplication方法又会一直调用到ApplicationThreadbindApplication方法,最终一直到ActivityThread.handleBindApplication方法:

private void handleBindApplication(AppBindData data) {
    ...
    try {
        final ClassLoader cl = instrContext.getClassLoader();
        // 注释1
        mInstrumentation = (Instrumentation)
            cl.loadClass(data.instrumentationName.getClassName()).newInstance();
    } catch (Exception e) {
        throw new RuntimeException(
            "Unable to instantiate instrumentation "
            + data.instrumentationName + ": " + e.toString(), e);
    }

    final ComponentName component = new ComponentName(ii.packageName, ii.name);
    mInstrumentation.init(this, instrContext, appContext, component,
            data.instrumentationWatcher, data.instrumentationUiAutomationConnection);
    ...
    
    try {
        ...
        try {
            // 注释2
            app = data.info.makeApplication(data.restrictedBackupMode, null);
            ...
            if (!data.restrictedBackupMode) {
                if (!ArrayUtils.isEmpty(data.providers)) {
                    // 注释3
                    installContentProviders(app, data.providers);
                    ...
                }
            }
            // 注释4
            mInstrumentation.onCreate(data.instrumentationArgs);
        } catch (Exception e) {
            throw new RuntimeException(
                "Exception thrown in onCreate() of "
                + data.instrumentationName + ": " + e.toString(), e);
        }
        try {
            // 注释5
            mInstrumentation.callApplicationOnCreate(app);
        } catch (Exception e) {
            if (!mInstrumentation.onException(app, e)) {
                throw new RuntimeException(
                  "Unable to create application " + app.getClass().getName()
                  + ": " + e.toString(), e);
            }
        }
    }
    ...
}

注释1处会初始化Instrumentation,Instrumentation类非常重要,后文会介绍到。这里主要是用它在注释2、注释4、注释5处创建Application并调用Application的onCreate生命周期。创建Application时会先调用Application的attachBaseContext方法。另外,注释3处我们可以看到,随着应用进程的启动,ContentProviders也会被启动

小结:

应用进程启动流程可以用下图概括:

三、Activity启动过程

上一节我们已经将APP进程成功启动,但我们的页面还没有起来,这一节我们就讲一下Activity是如何启动的。

让我们回忆一下,上一节最开始是Launcher进程请求AMS创建APP的启动页面,那么Launcher进程的桌面实际上也是一个Activtiy,它启动我们的启动页面,也是调用的Activity#startActivity()方法。一层层进去后,我们可以发现,实际上是调用的InstrumentationexecStartActivity()方法:

#Activity

public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
        @Nullable Bundle options) {
    if (mParent == null) { //注释1
        options = transferSpringboardActivityOptions(options);
        Instrumentation.ActivityResult ar =
            mInstrumentation.execStartActivity(
                this, mMainThread.getApplicationThread(), mToken, this,
                intent, requestCode, options);  //注释2
        ……
    } else {
        if (options != null) {
            mParent.startActivityFromChild(this, intent, requestCode, options);
        } else {
            // Note we want to go through this method for compatibility with
            // existing applications that may have overridden it.
            mParent.startActivityFromChild(this, intent, requestCode);
        }
    }
}

注释1处可以看到,mParent代表着上一个页面,打开启动页面时mParent为null,调用注释2处Instrumentation的execStartActivity()方法。 Instrumentation不止执行startActivity,它还负责了所有Activity生命周期的调用:

但它也不是真正的执行者,它只是包装了一下,为什么要这样包装呢?个人理解是因为Instrumentation需要对这些行为加一下监控,它的成员变量mActivityMonitors就是这个作用。

我们再进去看Instrumentation的execStartActivity()方法:

public ActivityResult execStartActivity(
        Context who, IBinder contextThread, IBinder token, Activity target,
        Intent intent, int requestCode, Bundle options) {
    ……
    try {
        intent.migrateExtraStreamToClipData();
        intent.prepareToLeaveProcess(who);
        int result = ActivityManager.getService()
            .startActivity(whoThread, who.getBasePackageName(), intent,
                    intent.resolveTypeIfNeeded(who.getContentResolver()),
                    token, target != null ? target.mEmbeddedID : null,
                    requestCode, 0, null, options); //注释1
        checkStartActivityResult(result, intent);
    } catch (RemoteException e) {
        throw new RuntimeException("Failure from system", e);
    }
    return null;
}

注释1处实际上是获取了ActivityManager.getService()去调用的startActivity。那这个ActivityManager.getService()又是何方神圣呢?再往下走

private static final Singleton<IActivityManager> IActivityManagerSingleton =
        new Singleton<IActivityManager>() {
            @Override
            protected IActivityManager create() {
                final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);//注释1
                final IActivityManager am = IActivityManager.Stub.asInterface(b);
                return am;
            }
        };

注释1处我们可以看到ActivityManager.getService()最终拿到的就是IActivityManager。这段代码采用的是AIDL(不了解的同学可以自行学习一下),拿到AMS在APP进程的代理对象IActivityManager。那么最终调用的startActivity也就是AMS的startActivity了。

AMS在startActivity的过程中也会进行一个比较长的链路,主要是校验权限、处理ActivityRecord、Activity任务栈等,这里不细说,最终会调用到app.thread.scheduleLaunchActivity方法。app.thread实际上是IApplicationThread,跟之前的IActivityManager一样,它也是一个代理对象,代理的是app进程的ApplicationThreadApplicationThread属于ActivityThread的内部类,我们可以看它的scheduleLaunchActivity方法:

@Override

public final void scheduleLaunchActivity(Intent intent, IBinder token, int ident,

    ActivityInfo info, Configuration curConfig, Configuration overrideConfig,

    CompatibilityInfo compatInfo, String referrer, IVoiceInteractor voiceInteractor,

    int procState, Bundle state, PersistableBundle persistentState,

    List<ResultInfo> pendingResults, List<ReferrerIntent> pendingNewIntents,

    boolean notResumed, boolean isForward, ProfilerInfo profilerInfo) {

    updateProcessState(procState, false);

    ActivityClientRecord r = new ActivityClientRecord(); // 注释1

    r.token = token;

    r.ident = ident;

    ...

    updatePendingConfiguration(curConfig);

    sendMessage(H.LAUNCH_ACTIVITY, r); // 注释2

}

注释1处会将启动Activity的参数封装成ActivityClientRecord。注释2处发送消息给H。之所以发给H是因为ApplicationThread本身是一个Binder对象,它执行scheduleLaunchActivity时处于Binder线程中,所以我们需要通过H转换到主线程中来。

H接收到LAUNCH_ACTIVITY消息后,会调用handleLaunchActivity方法:

private void handleLaunchActivity(ActivityClientRecord r, Intent customIntent, String reason) {

    ...

    Activity a = performLaunchActivity(r, customIntent);//注释1

    if (a != null) {

        r.createdConfig = new Configuration(mConfiguration);

        reportSizeConfigurations(r);

        Bundle oldState = r.state;

        handleResumeActivity(r.token, false, r.isForward,//注释2

        !r.activity.mFinished && !r.startsNotResumed, r.lastProcessedSeq, reason);

    if (!r.activity.mFinished && r.startsNotResumed) {
        ...

        performPauseActivityIfNeeded(r, reason);//注释3

    } else {

       ...

    }

}

看注释2与注释3处的代码,可以联想到它们必然与Activity生命周期有关,并且这里也解释了为什么上一个页面的onPause生命周期是在下一个页面的onResume之后调用的。注释1处performLaunchActivity方法很重要,我们来看一下:

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {

...

    try {
        // 注释1

        java.lang.ClassLoader cl = appContext.getClassLoader();

        activity = mInstrumentation.newActivity(

        cl, component.getClassName(), r.intent);

        ...

    } catch (Exception e) {

        ...

    }

    try {

        Application app = r.packageInfo.makeApplication(false, mInstrumentation); // 注释2

        ...
        // 注释3

        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); // 注释4

        }

        activity.mCalled = false;
        
        // 注释5
        if (r.isPersistable()) {

            mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);

        } else {

            mInstrumentation.callActivityOnCreate(activity, r.state);

        }

        ...
        // 注释6

        if (!r.activity.mFinished) {

            activity.performStart();

            r.stopped = false;

        }

        ...
        // 注释7

        if (r.state != null || r.persistentState != null) {

            mInstrumentation.callActivityOnRestoreInstanceState(activity, r.state,

        r.persistentState);

        }

        } else if (r.state != null) {

            mInstrumentation.callActivityOnRestoreInstanceState(activity, r.state);

        }

    }

    ...

}

注释1处通过反射创建了Activity实例。注释2处调用r.packageInfo.makeApplication创建Application,如果这里Application已经在ActivityThread#handleBindApplication()阶段创建了,就会直接返回。

注释3处调用了Activity的attach方法,内部会创建PhoneWindow绑定Activity。注释4设置Activity的主题,注释5调用Activity的onCreate()生命周期,注释6处调用onStart()生命周期,注释7当Activity恢复时调用OnRestoreInstanceState()生命周期。再结合上面有提到的onResume()生命周期,我们Activity的启动过程就已经讲完了。当然,这里走的是启动Activity的链路,非启动Activity的逻辑其实大差不差,大家有兴趣可以自行看一下源码。

小结

启动页面启动过程中各进程的交互关系:

启动页面启动过程可用下图概括:

四、Context

APP中到底有多少个Context呢?答案是Activity的数量+Service的数量+1,1是指Application。

如上图所示,ContextImplContextWrapper都继承于Context,并且Context具体的实现类是ContextImpl,作为ContextWrapper的成员变量mBase存在。Activity、Service、Application都继承于ContextWrapper,都属于Context,但它们都是靠ContextImpl去实现Context相关的功能的。

ContextImpl具体的创建时机是在Activity、Service、Application创建的时候,调用attachBaseContext()方法,具体代码这边就不贴了,感兴趣的同学可以自行查阅一下。

Application#getApplicationContext()方法获取到的是什么呢?

# ContextImpl

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

最终调用到ContextImpl#getApplicationContext()方法,可以看到最终返回Context的是Application对象。而fragment#getContext()返回的Context是Activity对象。

五、View的工作原理

View与Window是相辅相成的两个存在,本节先介绍View,涉及到Window的知识点可以先不管,下一节会详细介绍。可以先把Window理解成画布,View理解成画,画总是要在画布上才能渲染的,也就是说View必须要借助于Window才能展示出来。

View的绘制流程

每一个View都会有一个ViewRootImpl对象,View绘制的起点就是ViewRootImpl的perfromTraversals方法,在perfromTraversals内部会调用measure、layout、draw这三大流程。

measure是为了测量出View的尺寸大小,measure又会调用onMeasure,在onMeasure中又会先对子View进行测量,最终得到整个View的大小。measure过程之后,我们就已经可以调用View#getMeasuredWidth()View#getMeasuredHeight()获取到View的测量宽高了。

layout过程决定了View在父容器中的位置与实际宽高,也即是View的四个顶点坐标的位置。layout与measure相反,layout是先得到父View的位置,再onLayout递归下去得到子View的位置的。layout过程后,View#getWidth()View#getHeight()才有值,是View的实际宽高。

draw是绘制的最后一步,调用onDraw方法将View绘制在屏幕上。当然,子View也是会一层一层递归(通过dispatchDraw方法)调用onDraw方法往下走的。

Measure

绘制流程中比较重要的个人感觉是measure方法,所以单拿出来说一说。我们在看measure相关方法的时候一定会看到MeasureSpec这个参数,它是什么东西呢?

MeasureSpec是一个32位的int值,高2位代表SpecMode,低30位代表SpecSize。前者表示模式,后者表示大小,用1个值包含了两种含义。我们可以通过MeasureSpec#makeMeasureSpec(size, mode)方法合成MeasureSpec,也可以通过MeasureSpec#getMode(spec)MeasureSpec#getSize(spec)获取到MeasureSpec中的Mode或者Size只有生成了子View的MeasureSpec,我们才能够通过调用子View的measure方法测量出子View的大小。

那么MeasureSpec是怎么创建的呢?

SpecMode有三类,分别是UNSPECIFIED、EXACTLY、AT_MOST三种。UNSPECIFIED不用管,是系统内部使用的。那么什么时候是EXACTLY,什么时候是AT_MOST呢?有些同学可能知道,它的取值跟LayoutParams是有关系的。但需要注意的是,它不是完全由自身的LayoutParams决定的,LayoutParams需要和父容器一起才能决定View本身的SpecMode。当View是DecorView时,MeasureSpec根据窗口尺寸和自身的LayoutParams就能确定:

# ViewRootImpl

private boolean measureHierarchy(final View host, final WindowManager.LayoutParams lp,
        final Resources res, final int desiredWindowWidth, final int desiredWindowHeight) {
    ...
    childWidthMeasureSpec = getRootMeasureSpec(baseSize, lp.width); // 注释1
    childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height); // 注释2
    performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
    ...
}

注释1与注释2处获取的就是窗口尺寸与自身LayoutParams的尺寸。当顶层View的MeasureSpec确认后,在onMeasure方法中就会对下一层的View进行测量,获取子View的MeasureSpec。我们可以看一下ViewGroup的measureChildWithMargins方法,它在很多ViewGroup的onMeasure中都会被调用到:

protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); // 注释1

    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);  // 注释2
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec); // 注释3
}

我们可以看到注释1处首先是获取到子View自身的LayoutParams。然后再在注释2处根据父View的MeasureSpec以及paddingmargin,生成了子View的MeasureSpec。最后再通过注释3处调用measure测量出了子View的长度。那么父View的MeasureSpec传进去有什么用呢?后面代码有点长,我直接写结论:

当父View的MeasureMode是EXACTLY时,子View的LayoutParams如果是MATCH_PARENT或者写死的值,子View的MeasureMode是EXACTLY;子View的LayoutParams如果是WRAP_CONTENT,子View的MeasureMode是AT_MOST。

当父View的MeasureMode是AT_MOST时,子View的LayoutParams只有是写死的值时,子View的MeasureMode才会是EXACTLY,不然这种情况都是AT_MOST。

当父View的MeasureMode是AT_MOST时,子View的LayoutParams只有是写死的值时,子View的MeasureMode才会是EXACTLY,不然这种情况都是AT_MOST。`

有一个比较容易理解的方法是,AT_MOST通常对应的LayoutParams是WRAP_CONTENT。我们可以想想,父View如果是WRAP_CONTENT,子View即使是MATCH_CONTENT,那子View还不是相当于尺寸不确定吗?所以子View这种情况下的MeasureMode仍然是AT_MOST只有在尺寸确定的情况下,View的MeasureMode才会是EXACTLY。如果这里混乱的同学,可以再好好琢磨一下。

至于padding和margin,我们大概想一想就知道肯定是在测量长度时用到的。比如在计算子View长度时,肯定是需要把它的padding和margin都去掉才能准确。

另外,我们观察View的onMeasure方法,发现它提供了默认的实现:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

通过setMeasuredDimension(width, height)设置了View的宽高。但这里的getDefaultSize()是准确的吗?我们可以看一下:

public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);

    switch (specMode) {
    case MeasureSpec.UNSPECIFIED:
        result = size;
        break;
    case MeasureSpec.AT_MOST:
    case MeasureSpec.EXACTLY:
        result = specSize; // 注释1
        break;
    }
    return result;
}

注释1处我们可以看到,当SpecMode为AT_MOST时,默认直接用的是specSize。所以这里是有问题的,因为这个specSize代表父View的size,这样会造成LayoutParams为WRAP_CONTENT的效果是MATCH_PARENT的效果。所以很多官方的自定义View都是重写了onMeasure方法,自己去计算尺寸。我们在写自定义View时也需要特别注意这一点。

而ViewGroup是一个抽象类,它并没有实现View的onMeasure方法,那是因为每个ViewGroup的布局规则都不一样,自然测量的方式也会不同,需要各个子类自己去实现onMeasure。

layout过程

layout过程只需要知道,layout主要是父容器用来确定容易中子View位置的一个过程。当父容器确定位置后,在父容器的onLayout中会遍历其所有子元素调用它们的layout方法。而layout方法实际上就是确定四个顶点坐标的位置

我们可以看到onLayoutonMeasure方法类似,View和ViewGroup都没有实现它,因为每个View布局方式不同,需要自己实现。但因为在确定坐标过程中需要用到长和宽,所以layout的顺序排在measure的后面。

观察View#getWidth()View#getHeight()方法:

public final int getWidth() {
    return mRight - mLeft;
}

public final int getWidth() {
    return mRight - mLeft;
}

可以看到实际上它们就是坐标之间做了减法,所以这两个方法要在onLayout方法之后才能获取到真正的值。也就是View的实际宽高。一般情况下measureWidth与width是会相等的,除非我们特意重写了layout方法(layout方法与measure方法不一样,它是可以被重写的):

public void layout(int l, int t, int r, int b) {
    super.layout(l, t, r+11, b-11)
}

这样,实际宽高与测量宽高就会不一致了。

自定义View

自定义View一共分为四种类型:

  • 继承View
    • 这种类型常见于要绘制一些不规则的图形,需要重写它的onDraw方法。需要的注意的是,View的Padding属性在绘制过程中默认是不起作用的,如果要使Padding属性起作用,就需要自己在onDraw中获取Padding后绘制。另外,onMeasure时需要考虑wrap_content和padding的情况,上文也有提到过。
  • 继承ViewGroup
    • 这种类型比较少,一般用于实现自定义的布局。那就意味着要自定义布局规则,也就是自定义onMeasureonLayout。在onMeasure中,也需要处理wrap_content和padding的情况。另外在onMeasureonLayout中,还需要考虑padding与子元素margin共同作用的场景。
  • 继承某个特定的View,比如TextView
    • 这种类型一般用于扩展已有的某个View的功能,比较容易实现,不需要重写onMeasure或者onLayout方法。
  • 继承某个特定的ViewGroup,比如FrameLayout
    • 这种类型也很常见,一般用于将几个View组合到一起,但相对第二种方式会简单许多,也不需要重写onMeasure或者onLayout方法。

在自定义View中还需要额外注意的是,如果View中有额外开线程或者是动画的话,需要在合适的时机(比如onDetachedFromWindow生命周期)进行回收或者暂停,否则容易引起内存泄漏。另外,如果涉及到嵌套滑动的话可能还需要处理滑动冲突。

六、Window与WindowManager

Window相关类

Window是一个抽象类,它的具体实现是PhoneWindowPhoneWindowActivity#attach方法中被创建:

#Activity

final void attach(Context context...) {
    ...
    mWindow = new PhoneWindow(this, window, activityConfigCallback); // 注释1
    ...
    mWindow.setWindowManager(
        (WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
        mToken, mComponent.flattenToString(),
        (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0); // 注释2

}

注释1处初始化了PhoneWindow,注释2处给Window设置了WindowManagerWindowManager,顾名思义就是用来管理Window的,它继承了ViewManager接口:

public interface ViewManager
{
    public void addView(View view, ViewGroup.LayoutParams params);
    public void updateViewLayout(View view, ViewGroup.LayoutParams params);
    public void removeView(View view);
}

从上面的代码我们可以看出,管理Window,实际上就是管理View。context.getSystemService(Context.WINDOW_SERVICE)方法最终获取的是WindowManager的实现类WindowManagerImpl,我们可以观察WindowManagerImpl中的这三个方法,如addView:

@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
    applyDefaultToken(params);
    mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}

可以看到实际上addView是由mGlobal去实现的,WindowManagerImpl只是桥接了一下。这个mGlobalWindowManagerGlobal,是个单例,全局唯一:

#WindowManagerImpl

private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();

Window类型

Window类型具体为Window的Type属性。Type属性其实是一个int指,分为三大类型:

  • 应用程序窗口
    • 常见的Activity的Window就属于应用程序窗口,它的int值范围是1-99。
  • 子窗口
    • 子窗口代表着要依附于其它窗口才能存在,比如PopupWindow就属于一个子窗口,它的int值范围是1000-1999。
  • 系统窗口
    • Toast、音量条、输入法的窗口就属于系统窗口,int值范围是2000-2999。当我们要创建一个系统窗口时,需要申请系统权限android.permission.SYSTEM_ALERT_WINDOW才可以。

一般来说,type属性值越大,那么Z-Order的排序就越靠前,窗口越接近用户。

Window的操作

添加View

我们延续上方的addView代码,看一下这里面的过程:

# WindowManagerImpl
@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
    applyDefaultToken(params); // 注释1
    mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}

private void applyDefaultToken(@NonNull ViewGroup.LayoutParams params) {
    // Only use the default token if we don't have a parent window.
    if (mDefaultToken != null && mParentWindow == null) {
        if (!(params instanceof WindowManager.LayoutParams)) {
            throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
        }

        // Only use the default token if we don't already have a token.
        final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
        if (wparams.token == null) {
            wparams.token = mDefaultToken;
        }
    }
}

# WindowManagerGlobal
public void addView(View view, ViewGroup.LayoutParams params,
        Display display, Window parentWindow) {
    ...
    root = new ViewRootImpl(view.getContext(), display); // 注释2

    view.setLayoutParams(wparams);

    // 注释3
    mViews.add(view);
    mRoots.add(root);
    mParams.add(wparams);

    // do this last because it fires off messages to start doing things
    try {
        // 注释4
        root.setView(view, wparams, panelParentView);
    } catch (RuntimeException e) {
        // BadTokenException or InvalidDisplayException, clean up.
        if (index >= 0) {
            removeViewLocked(index, true);
        }
        throw e;
    }
}

注释1调用了applyDefaultToken方法,将token放在了LayoutParams中。我们可以发现Window的很多方法中都有这个token,这个token到底是什么,有什么作用呢?实际上这个token是一个IBinder对象,是AMS创建Activity时就会创建的,用来唯一标识一个Activity,创建后WMS也会拿到一份储存起来。当AMS调用scheduleLauncherActivity方法转回到APP进程时,会将这个token传到APP进程中。当我们在APP进程对Window进行操作时,就会用到这个token。因为最终Window的操作是在WMS,所以我们调用WMS方法时会将这个token传过去,WMS就会比对这个token和最开始存储的token,以此来找到这个Window是属于哪个Activity。、

再继续回来,我们看注释2处WindowManagerGlobal#addView方法中每次都会new一个ViewRootImpl对象。ViewRootImpl我们很熟悉,上一节有讲到,它负责View的绘制工作。这里它不仅负责View的绘制,还负责与最终的WMS进行通信。具体可以看到注释4处调用了ViewRootImpl#setView方法,内部再调用IWindowSession#addToDisplay方法。

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    ...
    res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
        getHostVisibility(), mDisplay.getDisplayId(), mWinFrame,
        mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
        mAttachInfo.mOutsets, mAttachInfo.mDisplayCutout, mInputChannel);
    ...
}

IWindowSession实际上是WMS的Session对象在APP进程的代理,这样我们的逻辑就到了WMS那边了,WMS会完成剩下的addView操作,包括为添加的窗口分配Surface,确定窗口显示次序,最后将Surface交给SurfaceFlinger处理,合成到屏幕上进行显示。并且,addToDisplay方法的第一个参数mWindow,是ViewRootImpl的内部类W,它是app进程的Binder实现类,WMS可以通过它调用app进程的方法。

另外我们可以看到WindowManagerGlobal在注释3处维护了三个列表,一个是View,一个是ViewRootImpl,一个是Params布局参数,在更新Window/移除Window时会用到。

小结

添加View的过程可用下图概括:

更新Window

更新Window的过程与添加的过程是类似的,所经过的类关系一模一样。主要的区别在WindowManagerGlobal中需要从列表中获取到ViewRootImpl,并更新布局参数:

public void updateViewLayout(View view, ViewGroup.LayoutParams params) {
    ...
    synchronized (mLock) {
        int index = findViewLocked(view, true);
        ViewRootImpl root = mRoots.get(index);
        mParams.remove(index);
        mParams.add(index, wparams);
        root.setLayoutParams(wparams, false);
    }
}

ViewRootImpl会调用scheduleTraversals方法重新绘制页面,最终在performTraversals方法中调用IWindowSession#relayout方法更新Window,并重新触发View的三大绘制流程。

Activity渲染

在第三节我们讲了Activity的启动过程,但其实还没有完全讲完,因为Activity实际上是没有渲染出来的。那Activity是怎么渲染出来的呢?当然也是靠Window。

当界面可与用户进行交互时,AMS会调用ActivityThreadhandleResumeActivity方法:

public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward, String reason) {
    ...
    final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason); // 注释1
    ...
    if (r.window == null && !a.mFinished && willBeVisible) {
        r.window = r.activity.getWindow();
        View decor = r.window.getDecorView();
        decor.setVisibility(View.INVISIBLE);
        ViewManager wm = a.getWindowManager(); 
        WindowManager.LayoutParams l = r.window.getAttributes();
        a.mDecor = decor;
        l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
        l.softInputMode |= forwardBit;
        ...
        if (a.mVisibleFromClient) {
            if (!a.mWindowAdded) {
                a.mWindowAdded = true;
                wm.addView(decor, l); // 注释2
            } else {
            ...
        }
        ...

注释1处调用performResumeActivity方法,内部会触发Activity的onResume生命周期。注释2处用WindowManager#addView方法将decorView绘制到了Window上,这样整个Activity就渲染出来了。这也是Activity在onResume生命周期才会显示出来的原因。

Dialog、PopupWindow、Toast

最后,还是觉得有必要搞明白Dialog、PopupWindow、Toast这三个东西到底是什么玩意儿。毫无疑问,它们都是通过Window渲染的,但Dialog属于应用程序窗口,PopupWindow属于子窗口,Toast属于系统级窗口。

Dialog的创建时,跟Activity一样会创建一个PhoneWindow:

Dialog(@NonNull Context context, @StyleRes int themeResId, boolean      createContextThemeWrapper) {
    ···
    mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

    final Window w = new PhoneWindow(mContext); // 注释1
    mWindow = w;
    w.setCallback(this);
    w.setOnWindowDismissedCallback(this);
    w.setOnWindowSwipeDismissedCallback(() -> {
        if (mCancelable) {
            cancel();
        }
    });
    w.setWindowManager(mWindowManager, null, null);
    w.setGravity(Gravity.CENTER);

    mListenersHandler = new ListenersHandler(this);
}

我们看到注释1处Dialog初始化时是创建了自己的Window,所以它不依附于其它Window存在,不属于子窗口,而是一个应用程序窗口。

Dialog#show()时,就会用WindowManager将DecorView添加到窗口上,移除时同样是将DecorView从窗口上移除。有一个特殊之处是,普通的dialog的context必须是Activity,否则show时会报错,因为添加窗口时需要校验Activity的token,除非我们把这个dialog的window设置成系统窗口,就不需要了。

而PopupWindow为什么就属于子窗口呢?我们可以查阅PopupWindow的源码:

# PopupWindow

private int mWindowLayoutType = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL;
public PopupWindow(View contentView, int width, int height, boolean focusable) {
    if (contentView != null) {
        mContext = contentView.getContext();
        // 注释1
        mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); 
    }

    setContentView(contentView);
    setWidth(width);
    setHeight(height);
    setFocusable(focusable);
}

public void showAtLocation(IBinder token, int gravity, int x, int y) {
    ...
    final WindowManager.LayoutParams p = createPopupLayoutParams(token); // 注释2
    preparePopup(p);

    ...
    invokePopup(p);
}
   
protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) {
    final WindowManager.LayoutParams p = new WindowManager.LayoutParams();
    p.gravity = computeGravity();
    p.flags = computeFlags(p.flags);
    p.type = mWindowLayoutType; // 注释3
    p.token = token; // 注释4
    p.softInputMode = mSoftInputMode;
    p.windowAnimations = computeAnimationResource();
    ...
}
    
private void invokePopup(WindowManager.LayoutParams p) {
    ...
    final PopupDecorView decorView = mDecorView;
    decorView.setFitsSystemWindows(mLayoutInsetDecor);

    setLayoutDirectionFromAnchor();

    mWindowManager.addView(decorView, p); // 注释5
    ...
}

在源码中,我们可以看到PopupWindow中不会有任何新建Window的操作,因为它依赖的是别人的Window。在注释1处拿到WindowNManager,注释2处调用createPopupLayoutParams方法给Window参数赋值。尤其注意注释3和注释4处两个字段,type字段即代表了窗口的类型,我们可以看到mWindowLayoutType的值是WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,这就代表了是子窗口。且注释4处的token,不再是Activity的token了,而是show的时候传进来的View的token。

Toast也是类似,我们可以看到它的源码中WindowParams.LayoutParams.type的值是WindowManager.LayoutParams.TYPE_TOAST,对应着系统窗口。具体内部机制这里就不分析了,感兴趣的同学可以自行查阅。

七、Handler消息机制

Handler消息机制,最主要的作用就是在多线程的背景下,通过消息机制,可以从子线程切换到主线程进行UI的更新操作,并且保证了线程安全。

首先介绍其中的主要成员以及他们之间的关系

  • Message
    • 消息,可用来存放数据,通过Message.obtain()可从缓存池中获取一个消息对象。
  • MessageQueue
    • 用于存储消息的队列。
  • Looper
    • loop方法会持续监听MessageQueue,当MessageQueue中有消息时,将消息拿出来进行分发。
    • 一个线程只会有一个Looper,一个Looper对应一个MessageQueue,在Loope创建时,对应的MessageQueue就会创建。一个Handler也只能绑定一个Looper,但是一个Looper可以绑定多个Handler,Looper是以线程为单位的
    • Looper线程隔离,是通过Threadlocal实现的。Looper下有个静态变量sThreadLocal,每个线程下调用Looper.myLooper()时就是从这个变量中拿,获取当前线程下的Looper。
    • Looper分为子线程Looper与MainLooper。MainLooper在ActivityThread的main方法中就会通过Looper.prepareMainLooper()方法准备好。它们的区别一个是可以quit的,一个是不能quit的。所谓的quit就是调用looper.quit方法,实际上是在MessageQueue中进行quit,将其中的消息给移除。quit又分是否安全quit。safequit下,会先将消息队列中已有的消息先发完再quit。
  • Handler
    • 用于发送Message与接收Message,作为Message中的target
  • Message.Callback
    • 当Message.callback存在时,Handler将接收不到Message,而是直接回调到callback中。 Handler在post一个Runnable时,实际上就是发送一个消息并将这个Runnable作为这个消息的callback存在。

那整个消息通信的流程又是什么样的呢?我们可以串起来说一下。

  1. 首先通信准备阶段,Looper.prepare()给当前线程创建一个Looper,同时创建一个MessageQueue。 new一个Handler。
  2. handler.post或者handler.sendMessage发送一个消息,入队到MessageQueue。
  3. Looper将MessageQueue中的队列按优先级分发给Handler。
  4. Handler获取到Message,并根据之中的数据处理消息。

在写业务的时候我们常常会post/send一个延时消息,这个延时消息是怎么实现的呢?每个Message都有一个when字段,对应着它什么时候需要被分发。在入队到MessageQueue时,就会根据when的先后进行排列。当loop取消息时,会判断这个消息的分发时间是否到了,如果还没到就先不分发,等时间到了再分发。

所以在消息机制中,并不是发一个消息就能够保证它马上会被处理的,因为机制内部就有优先级排列。那怎么能够在我们需要的时候提高消息的优先级呢?我们可以调用postAtFrontOfQueue以及sendMessageAtFrontOfQueue将消息放到队头,其实是将这个消息的when设置为0。或者,使用异步消息与同步屏障。

异步消息与同步屏障

异步消息与同步屏障其实我们开发时很少会用到,一般都是系统自己使用,同步屏障的接口也是hide的,我们要调用只能反射调用。但因为面试很常见,这里也顺带提一下。

异步消息的创建我们可以在创建Message时调用setAsynchronous方法将Message设置为异步消息,或者Handler创建时也可以传参选择是否为异步,如果是异步的话,Handler发送的所有消息都为异步消息。

那什么又是同步屏障呢?同步屏障的本质其实就是特殊的Message,特殊在于这个Message的Target不是Handler,而是null,以此与其它Message区分。我们可以通过反射调用MessageQueue的postSyncBarrier方法发送一个同步屏障,以及removeSyncBarrier方法进行移除。

我们可以将同步屏障与异步消息结合,以此来提高异步消息是被优先执行的。具体原理是MessageQueue在Loop取出Message时,会判断Message是否为同步屏障。若为同步屏障,则需要在队列中找到第一个异步消息进行优先处理,而不是处理排在前面的同步消息:

#MessageQueue

Message next() {
    ...
    synchronized (this) {
        // Try to retrieve the next message.  Return if found.
        final long now = SystemClock.uptimeMillis();
        Message prevMsg = null;
        Message msg = mMessages;
        if (msg != null && msg.target == null) {
            // Stalled by a barrier.  Find the next asynchronous message in the queue.
            do {
                prevMsg = msg;
                msg = msg.next;
            } while (msg != null && !msg.isAsynchronous()); // 注释1
        }
    ...
}

注释1处当发现msg.target为null时,代表着这个消息是同步屏障,就会去拿队列中第一个异步消息。

上文中讲到过view的更新操作,其中在最后的绘制阶段ViewRootImpl会通过requestLayout()进行布局的更新,requestLayout()内部又调用scheduleTraversals()方法从而重新走一遍三大绘制流程。

# ViewRootImpl

void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier(); // 注释1
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null); //注释2
        if (!mUnbufferedInputDispatch) {
            scheduleConsumeBatchedInput();
        }
        notifyRendererOfFramePending();
        pokeDrawLockIfNeeded();
    }
}

在这个方法中我们没有看到三大绘制流程的触发,而是在注释1处发了一个同步屏障,阻塞主线程消息队列。同时在注释2处监听VSYNC信号。当VSYNC信号来时,Choreographer会发一个异步消息,这个异步消息在同步屏障的帮助下将会得到执行,并且触发监听回调。监听回调中ViewRootImpl将移除同步屏障,并调用performTraversals()执行三大绘制流程刷新UI。

总结

本篇文章一共介绍了七点Framework知识,分别是:系统启动流程、应用进程启动流程、Activity启动过程、Context、View的工作原理、Window与WindowManager、Handler消息机制。这几点都是笔者目前认为Android业务开发需要掌握的知识点,它们有些是面试常问的,有些是平时开发或多或少会接触到的,甚至我们做Hook与插件化时都会用到里面的知识(后面会专门出一篇文章讲Hook与插件化)。像SystemServer进程的代码本章一律没提,因为笔者自己也不太会,就不献丑啦,而且平时的确也没用到过。当然,这篇文章会随时更新,随时填补知识的空白,哈哈~希望以上内容可以帮到大家!

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1079791.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

第二证券:A股公司首批三季报出炉 柏楚电子、平煤股份业绩一增一减

10月10日晚&#xff0c;柏楚电子、平煤股份拉开了A股公司三季报发表序幕。来自激光切开控制体系赛道的柏楚电子&#xff0c;前三季度营收、净利润均完结较大崎岖增加&#xff1b;焦煤龙头企业平煤股份&#xff0c;受煤价跌落连累成果&#xff0c;前三季度营收、净利润均有所下降…

Java架构师缓存性能优化

目录 1 缓存的负载策略2 缓存的序列化问题3 缓存命中率低4 缓存对数据库高并发访问5 缓存数据刷新的策略6 何时写缓存7 批量数据来更新缓存8 缓存数据过期的策略9 缓存数据如何恢复10 缓存数据如何迁移11 缓存冷启动和缓存预热1 缓存的负载策略 如果说我们在缓存架构设计当中啊…

优思学院|八大浪费深度剖析

在工作流程中消除浪费是精益思想的目标。在深入探讨八大浪费之前&#xff0c;了解浪费的定义至关重要。浪费是指工作流程中的任何行动或步骤&#xff0c;这些行动或步骤不为客户增加价值。换句话说&#xff0c;浪费是客户不愿意为其付费的任何过程。 最初的七大浪费&#xff0…

第83步 时间序列建模实战:Catboost回归建模

基于WIN10的64位系统演示 一、写在前面 这一期&#xff0c;我们介绍Catboost回归。 同样&#xff0c;这里使用这个数据&#xff1a; 《PLoS One》2015年一篇题目为《Comparison of Two Hybrid Models for Forecasting the Incidence of Hemorrhagic Fever with Renal Syndr…

Nerf 学习笔记

Nerf 学习笔记 Step 1&#xff1a;相机 Rays 行进(ray marching)Step 2&#xff1a;收集查询点Step 3&#xff1a;将查询点投射到高维空间(位置编码)Step 4&#xff1a;神经网络推理和体渲染神经网络推理体渲染计算损失 Reference: 搞懂神经辐射场 Neural Radiance Fields (Ne…

如何在一个传统的html中,引入vueJs并使用vue复制组件?

如何在一个传统的html中&#xff0c;引入vueJs并使用vue复制组件&#xff1f; 1.1 引言1.2 背景1.3 解决方案1.3.1 解决方案一&#xff1a;直接使用clipboard(不推荐仅供参考学习)1.3.2 解决方案二&#xff1a;封装指令js库后使用 (推荐) 1.1 引言 这篇博文主要分享如何在一个…

Springboot给每个接口设置traceId,并添加到返回结果中

原理 slf4j有个MDC的类&#xff0c;是ThreadLocal的实现&#xff0c;保存在这里的变量会绑定到某个请求线程&#xff0c;于是在该请求的线程里的日志代码都可以使用设入的变量。 实现 一、引入依赖 这个是可选项&#xff0c;用于生成唯一uid&#xff0c;我人懒&#xff0c…

一文带你了解 Linux 的 Cache 与 Buffer

目录 前言一、Cache二、Buffer三、Linux 系统中的 Cache 与 Buffer总结 前言 内存的作用是什么&#xff1f;简单的理解&#xff0c;内存的存在是为了解决高速传输设备与低速传输设备之间数据传输速度不和谐而设立的中间层&#xff08;学过计算机网络的应该都知道&#xff0c;这…

【实战】kubeadmin安装kubernetes集群

文章目录 前言服务器介绍准备工作设置服务器静态ip修改host关闭防火墙和swap修改所需的内核参数 部署步骤安装containerd安装cri工具&#xff08;效果等同于docker&#xff09; 安装kubernetes集群安装网络插件flannel安装可视化面板kuboard&#xff08;可选&#xff09; 下期预…

42. QT中开发Android配置QFtp功能时遇到的编译问题

1. 说明 此问题仅适用在QT中开发Android程序时&#xff0c;需要适用QFtp功能的情况。一般情况下&#xff0c;如果开发的是Windows或者Linux系统下的程序&#xff0c;可能不会出现该问题。 2. 问题 【Android】在将QFtp的相关代码文件加入到项目中后&#xff0c;编译项目时会…

sql server判断两个集合字符串是否存在交集

sql server判断字符串A101;A102和字符串A102;A103是否存在交集 我们编写两个函数&#xff1a; 1&#xff09;函数fn_split将字符串拆分成集合 create function [dbo].[fn_split](inputstr varchar(8000), seprator varchar(10)) returns temp table (Result varchar(200)) a…

TCP/IP(七)TCP的连接管理(四)全连接

一 全连接队列 nginx listen 参数backlog的意义 nginx配置文件中listen后面的backlog配置 ① TCP全连接队列概念 全连接队列: 也称 accept 队列 ② 查看应用程序的 TCP 全连接队列大小 实验1&#xff1a; ss 命令查看 LISTEN状态下 Recv-Q/Send-Q 含义附加&#xff1a;…

2785323-77-3,MAL-Alkyne,双功能连接试剂Alkyne maleimide

炔烃马来酰亚胺&#xff0c;Alkyne maleimide,MAL-Alkyne是一种非常有用的双功能连接试剂&#xff0c;可以在生物分子中发挥重要的作用。它的马来酰亚胺基团可以与生物分子中的硫醇基团反应&#xff0c;形成共价键&#xff0c;从而将生物分子与炔烃连接起来。这种连接方式在生物…

React的类式组件和函数式组件之间有什么区别?

React 中的类组件和函数组件是两种不同的组件编写方式&#xff0c;它们之间有一些区别。 语法和写法&#xff1a;类组件是使用类的语法进行定义的&#xff0c;它继承自 React.Component 类&#xff0c;并且需要实现 render() 方法来返回组件的 JSX。函数组件是使用函数的语法进…

Linux|qtcreator编译可执行程序双击运行

qt GUI window移植到linux参见&#xff1a;VS|vs2017跨平台编译linux&&CConsole&&QtGUI 参考&#xff1a;QtCreator修改项目的生成目录 文章目录 双击.pro文件&#xff0c;点击configureproject构建项目切换到release模式下双击打开pro文件&#xff0c;修改依赖…

嵌入式Linux裸机开发(六)EPIT 定时器

系列文章目录 文章目录 系列文章目录前言介绍配置过程 前言 前面学的快崩溃了&#xff0c;这也太底层了&#xff0c;感觉学好至少得坚持一整年&#xff0c;我决定这节先把EPIT学了&#xff0c;下面把常见三种通信大概学一下&#xff0c;直接跳过其他的先学移植了&#xff0c;有…

项目平台——项目首页设计(五)

这里写目录标题 一、页面成果图展示二、滚动条组件的使用三、页面设计1、需要4个盒子2、项目名称样式设计3、对基本信息、bug统计、执行记录进行样式设计4、基本信息5、bug统计 四、echarts使用1、安装2、折线图的使用 一、页面成果图展示 二、滚动条组件的使用 当内容超过屏幕…

虹科技术 | 重磅更新!手持式PCAN-Diag FD现可扩展为J1939监控器

重磅更新&#xff01;现可将手持式PCAN-Diag FD现可扩展为J1939监控器&#xff0c;协助您的CAN总线通信诊断&#xff01; 文章目录 一、PCAN-Diag FD 功能更新二、PCAN-Diag FD 简介 一、PCAN-Diag FD 功能更新 PCAN-Diag FD可以监控CAN/CAN FD总线的通信情况&#xff0c;可以…

SAP 设置不能用ME52N修改PR,但需要PR的修改权限

SAP 设置不能用ME52N修改PR&#xff0c;但需要PR的修改权限 想到3个思路&#xff1a; 1.设置屏幕字段控制&#xff0c;分配用户参数 2.设置屏幕变式SHD0 3.增强逻辑控制 此处用增强示例&#xff1a;