[Framework] Android Handler 工作原理

作者:Tans5

Android 中的 Handler 都被人说烂了,但是还是想多说一次,因为在 Android 的系统中它真的非常重要而且它的机制并没有很复杂,无论是新手和老手都可以好好学习下,这对理解 Android 系统很重要,所以说学习的性价比非常高。
Android 中 IPC 跨进程通信主要依靠 Binder , 而同一个进程的通信就主要依靠 Handler

这里先简单描述下 Handler 的工作流程:
首先在 Handler 处理任务的线程需要调用 Looper#prepare() 方法,来为当前线程创建一个 Looper 实例,这个实例通过 ThreadLocal 存放,也就是一个线程只能有一个这个实例;Looper 构造函数中会初始化一个 MessageQueue 对象,MessageQueue 对象就是用来封装任务的队列,它是用的链表实现,而任务队列的 Item 就是 Message 对象,也就是单个的任务;任务处理线程调用 Looper#prepare() 方法完成后,会调用 Looper#loop() 方法,这个时候任务线程就会阻塞去处理对应 Looper 中的 MessageQueue 中的任务,处理的方式就是一个死循环从 MessageQueue 中去获取最新的 Message 获取到后,然后调用对应的 Handler#handleMessage() 方法去执行它,执行完成后又去获取下一个任务,如果没有新的任务就会陷入阻塞,等有新的任务来的时候会唤醒这个阻塞(这个唤醒机制是用的 Linux 中的 epoll 机制),继续执行新的任务。
上面大致描述了 LooperMessageQueue 如何从队列中获取新的任务和执行任务,这里再简单描述下怎么插入任务,首先要自定一个 Handler 对象,构造函数中需要传入上面所创建的 Looper 对象,其中自定义的 Handler#handleMessage 方法就是用来执行任务的方法,其他的线程需要向对应的 Looper 线程添加任务时,就调用上面 Handler 实例的 sendMessage 方法来添加任务,也就是向 MessageQueue 队列中添加任务,最终会在 Looper 所对应的线程执行,这样就完成了一次跨线程的通信。
下面是一个简单的 Handler 使用的代码:


override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    var handlerLooper: Looper? = null
    val handlerThread = object : Thread() {
        override fun run() {
            super.run()
            Looper.prepare()
            handlerLooper = Looper.myLooper()
            Looper.loop()
        }
    }
    handlerThread.start()
    while (handlerLooper == null) {
        // 等待 HandlerThread 初始化完成
    }
    val handler = object : Handler(handlerLooper!!) {
        override fun handleMessage(msg: Message) {
            super.handleMessage(msg)
            if (msg.what == 0) {
                // TODO: 处理任务,该方法最终工作在 HandlerThread 线程
            }
            
        }
    }
    
    val msg = handler.obtainMessage(0)
    // 发送的代码工作在主线程
    handler.sendMessage(msg)
}

Android 中有现成的 HandlerThread,不用自定义,我这里是为了展示这个过程。

Tips: 我后续的源码分析都是基于 andorid 31

Looper

Looper 初始化

// ...
public static void prepare() {
    prepare(true);
}

private static void prepare(boolean quitAllowed) {
    if (sThreadLocal.get() != null) {
        throw new RuntimeException("Only one Looper may be created per thread");
    }
    sThreadLocal.set(new Looper(quitAllowed));
}
// ...

// ...
private Looper(boolean quitAllowed) {
    mQueue = new MessageQueue(quitAllowed);
    mThread = Thread.currentThread();
}
// ...

初始化的过程非常简单,如果当前线程已经有 Looper 对象就直接报错,如果没有新建一个 Looper 对象放在 ThreadLocal 中,在 Looper 的构造函数中,创建了 MessageQueue 实例和保存了当前的 Thread 对象。

Looper 任务处理

// ...
public static void loop() {
    final Looper me = myLooper();
    if (me == null) {
        throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
    }
    if (me.mInLoop) {
        Slog.w(TAG, "Loop again would have the queued messages be executed"
                + " before this one completed.");
    }

    me.mInLoop = true;

    // ..

    me.mSlowDeliveryDetected = false;

    for (;;) {
        if (!loopOnce(me, ident, thresholdOverride)) {
            return;
        }
    }
}
// ...

Looper#loop() 方法也非常简单,修改了当前的 Looper 的一些状态,然后在死循环中无限调用 loopOnce() 方法去执行任务,如果该方法返回 false 就表示该 Looper 已经退出,就跳出循环。


private static boolean loopOnce(final Looper me,
        final long ident, final int thresholdOverride) {
    Message msg = me.mQueue.next(); // might block
    if (msg == null) {
        // No message indicates that the message queue is quitting.
        return false;
    }

    // This must be in a local variable, in case a UI event sets the logger
    final Printer logging = me.mLogging;
    if (logging != null) {
        logging.println(">>>>> Dispatching to " + msg.target + " "
                + msg.callback + ": " + msg.what);
    }
    
    // ...
    
    Object token = null;
    if (observer != null) {
        token = observer.messageDispatchStarting();
    }
    long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
    try {
        msg.target.dispatchMessage(msg);
        if (observer != null) {
            observer.messageDispatched(token, msg);
        }
        dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
    } catch (Exception exception) {
        if (observer != null) {
            observer.dispatchingThrewException(token, msg, exception);
        }
        throw exception;
    } finally {
        ThreadLocalWorkSource.restore(origWorkSource);
        if (traceTag != 0) {
            Trace.traceEnd(traceTag);
        }
    }
    
    
    if (logging != null) {
       logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
    }
    
    // ...

    msg.recycleUnchecked();

    return true;
}

直接调用 MessageQueue#next() 方法去获取 Message 对象,如果返回空就表示 Looper 已经退出了。这里有一个 Printer 对象,在 Message 执行前后都会打印对应的日志,可以通过调用 Looper#setMessageLogging() 方法来自定义这个对象,根据打印的日志内容我们就可以分辨是开始前还是开始后,在 APM 中经常通过这个方法来判断主线程的任务执行是否超时。执行任务会调用的是 handler.target#dispatchMessage() 方法,其实这个 target 对象就是 Handler,后续再分析这个方法。
这次看代码还有新发现,这里还多了一个 Observer 对象,我记得以前的旧版本是没有的,可以通过 Looper#setObserver() 方法来设置,它的接口如下:


public interface Observer {
    /**
     * Called right before a message is dispatched.
     *
     * <p> The token type is not specified to allow the implementation to specify its own type.
     *
     * @return a token used for collecting telemetry when dispatching a single message.
     *         The token token must be passed back exactly once to either
     *         {@link Observer#messageDispatched} or {@link Observer#dispatchingThrewException}
     *         and must not be reused again.
     *
     */
    Object messageDispatchStarting();

    /**
     * Called when a message was processed by a Handler.
     *
     * @param token Token obtained by previously calling
     *              {@link Observer#messageDispatchStarting} on the same Observer instance.
     * @param msg The message that was dispatched.
     */
    void messageDispatched(Object token, Message msg);

    /**
     * Called when an exception was thrown while processing a message.
     *
     * @param token Token obtained by previously calling
     *              {@link Observer#messageDispatchStarting} on the same Observer instance.
     * @param msg The message that was dispatched and caused an exception.
     * @param exception The exception that was thrown.
     */
    void dispatchingThrewException(Object token, Message msg, Exception exception);
}

Observer 可以监听任务开始,结束和异常,看上去它可以替换 Printer,不过它是对应用层隐藏的,需要骚操作。

Handler

Handler 初始化

public Handler(@NonNull Looper looper, @Nullable Callback callback, boolean async) {
    mLooper = looper;
    mQueue = looper.mQueue;
    mCallback = callback;
    mAsynchronous = async;
}

其中 LooperMessageQueue 就不用多说了;其中 mCallback 对象就是可以优先于 Handler#handleMessage 方法处理任务,也可以拦截任务不让 Handler#handleMessage 执行;async 是表示发送的消息是否是异步消息,这个和消息屏障关系密切,后续会再分析。

Handler 发送消息

private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
        long uptimeMillis) {
    msg.target = this;
    msg.workSourceUid = ThreadLocalWorkSource.getUid();
    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

无论是调用 Handler 的哪个方法发送消息,最终都会到 enqueueMessage() 方法里面去,在 Looper 中我们说到 target 就是 Handler 对象,在这里就得到验证了,如果 Handler 的构造函数中设置为异步,Message 也会被设置为异步,然后直接将处理好的 Message 通过 MessageQueue#enqueueMessage() 方法添加到队列中。

Handler 处理消息

Looper 下发消息时说到最后会调用 HandlerdispatchMessage() 方法:

public void dispatchMessage(@NonNull Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}

如果 Message 中有设置 Callback 就直接在对应的 Callback 中处理,不继续下发; 如果 Message 中没有设置 Callback,会先检查 Handler 是否有设置 mCallback,如果有设置,调用 mCallback 处理,mCallbackhandleMessage 返回 true 就表示要拦截该消息,如果不拦截就交由 HandlerhandleMessage() 方法处理。

MessageQueue

MessageQueue 插入任务

在讲 Handler 的时候说到插入消息会直接调用 MessageQueue#enqueueMessage() 方法:


boolean enqueueMessage(Message msg, long when) {
    if (msg.target == null) {
        throw new IllegalArgumentException("Message must have a target.");
    }

    synchronized (this) {
        if (msg.isInUse()) {
            throw new IllegalStateException(msg + " This message is already in use.");
        }

        if (mQuitting) {
            IllegalStateException e = new IllegalStateException(
                    msg.target + " sending message to a Handler on a dead thread");
            Log.w(TAG, e.getMessage(), e);
            msg.recycle();
            return false;
        }

        msg.markInUse();
        msg.when = when;
        Message p = mMessages;
        boolean needWake;
        if (p == null || when == 0 || when < p.when) {
            // New head, wake up the event queue if blocked.
            msg.next = p;
            mMessages = msg;
            needWake = mBlocked;
        } else {
            // Inserted within the middle of the queue.  Usually we don't have to wake
            // up the event queue unless there is a barrier at the head of the queue
            // and the message is the earliest asynchronous message in the queue.
            needWake = mBlocked && p.target == null && msg.isAsynchronous();
            Message prev;
            for (;;) {
                prev = p;
                p = p.next;
                if (p == null || when < p.when) {
                    break;
                }
                if (needWake && p.isAsynchronous()) {
                    needWake = false;
                }
            }
            msg.next = p; // invariant: p == prev.next
            prev.next = msg;
        }

        // We can assume mPtr != 0 because mQuitting is false.
        if (needWake) {
            nativeWake(mPtr);
        }
    }
    return true;
}

如果 Message 没有设置 Handler 直接抛出异常;如果 Message 已经是 inUse 状态,直接抛出异常;如果 MessageQueue 已经退出,就直接回收 Message;按照任务的触发时间由小到大,插入到 mMessage 链表中,最后如果需要唤醒 next() 方法中的 nativePollOnce() 方法,就会调用 nativeWake 方法,这是一个 C++ 实现的方法(内部实现其实是向一个管道 fd 中写入了一个数字 1,通过 epoll 机制,会监听到管道 fd 有数据写入,然后会唤醒 nativePollOnce() 方法,然后去读取新的 Message)。

MessageQueue 读取任务

在讲 Looper 时讲到最后获取任务是通过 MessageQueue#next() 方法:


Message next() {
    // Return here if the message loop has already quit and been disposed.
    // This can happen if the application tries to restart a looper after quit
    // which is not supported.
    final long ptr = mPtr;
    if (ptr == 0) {
        return null;
    }

    int pendingIdleHandlerCount = -1; // -1 only during first iteration
    int nextPollTimeoutMillis = 0;
    for (;;) {
        if (nextPollTimeoutMillis != 0) {
            Binder.flushPendingCommands();
        }
        // 等待 epoll 唤醒,唤醒是等待 nativeWake() 方法调用或者超时(超时时间为 nextPollTimeoutMillis)
        nativePollOnce(ptr, nextPollTimeoutMillis);

        synchronized (this) {
            // Try to retrieve the next message.  Return if found.
            final long now = SystemClock.uptimeMillis();
            Message prevMsg = null;
            Message msg = mMessages;
            // target 为空,就表示这是一个屏障的消息
            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());
            }
            if (msg != null) {
                if (now < msg.when) {
                    // Next message is not ready.  Set a timeout to wake up when it is ready.
                    // 当前消息的时间大于当前的时间,表示还没有到达执行的时间,计算间隔,等待重新进入 nativePollOnce 方法
                    nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                } else {
                    // 获取消息成功,把它从队列中移除,然后返回方法
                    // Got a message.
                    mBlocked = false;
                    if (prevMsg != null) {
                        prevMsg.next = msg.next;
                    } else {
                        mMessages = msg.next;
                    }
                    msg.next = null;
                    if (DEBUG) Log.v(TAG, "Returning message: " + msg);
                    msg.markInUse();
                    return msg;
                }
            } else {
                // No more messages.
                nextPollTimeoutMillis = -1;
            }

            // Process the quit message now that all pending messages have been handled.
            if (mQuitting) {
                dispose();
                return null;
            }
            
            // 后续就是在没有消息时回调 IdleHandler


            // If first time idle, then get the number of idlers to run.
            // Idle handles only run if the queue is empty or if the first message
            // in the queue (possibly a barrier) is due to be handled in the future.
            // 如果 pendingIdleHandlerCount 小于 0 就表示当前的这一次 next() 方法调用,还没有执行过 IdleHandler,就需要去读取 IdleHandler 的数量,在一次 next() 方法调用时最多执行一次 IdleHandler。
            if (pendingIdleHandlerCount < 0
                    && (mMessages == null || now < mMessages.when)) {
                pendingIdleHandlerCount = mIdleHandlers.size();
            }
            if (pendingIdleHandlerCount <= 0) {
                // 这里就表示 IdleHandler 为空,或者已经执行过了。
                // No idle handlers to run.  Loop and wait some more.
                mBlocked = true;
                continue;
            }

            if (mPendingIdleHandlers == null) {
                mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
            }
            mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
        }

        // Run the idle handlers.
        // We only ever reach this code block during the first iteration.
        for (int i = 0; i < pendingIdleHandlerCount; i++) {
            final IdleHandler idler = mPendingIdleHandlers[i];
            mPendingIdleHandlers[i] = null; // release the reference to the handler

            boolean keep = false;
            try {
                // 执行 idleHandler
                keep = idler.queueIdle();
            } catch (Throwable t) {
                Log.wtf(TAG, "IdleHandler threw exception", t);
            }
            
            // 如果不需要保留就把它移出
            if (!keep) {
                synchronized (this) {
                    mIdleHandlers.remove(idler);
                }
            }
        }

        // Reset the idle handler count to 0 so we do not run them again.
        // 这里设置为 0 后,IdleHandler 就不会再次执行,需要等待下次的 next() 方法调用
        pendingIdleHandlerCount = 0;

        // While calling an idle handler, a new message could have been delivered
        // so go back and look again for a pending message without waiting.
        nextPollTimeoutMillis = 0;
    }
}

通过 nativePollOnce() 方法等待唤醒(通过调用 nativeWake() 方法)或者超时(超时时间是 nextPollTimeoutMillis),唤醒后直接获取最新的一条 Message(先跳过同步消息逻辑),然后判断任务执行的时间,如果大于当前时间就继续等待,直到可以执行,如果小于当前时间直接返回,同时把这条消息从队列中移除(先跳过 IdleHandler 逻辑)。

同步消息和消息屏障

如果 target 对象为空就表示该消息是一个同步屏障,如果是同步屏障的话他就会重新再去取该同步屏障消息后面的最新一个异步消息去执行,如果没有异步消息就会去等待。
通过以下方法添加屏障消息:


private int postSyncBarrier(long when) {
    // Enqueue a new sync barrier token.
    // We don't need to wake the queue because the purpose of a barrier is to stall it.
    synchronized (this) {
        final int token = mNextBarrierToken++;
        final Message msg = Message.obtain();
        msg.markInUse();
        msg.when = when;
        msg.arg1 = token;

        Message prev = null;
        Message p = mMessages;
        if (when != 0) {
            while (p != null && p.when <= when) {
                prev = p;
                p = p.next;
            }
        }
        if (prev != null) { // invariant: p == prev.next
            msg.next = p;
            prev.next = msg;
        } else {
            msg.next = p;
            mMessages = msg;
        }
        return token;
    }
}

其实就是在消息队列中添加了一个 target 为空的消息,同时在 Messagearg1 对象中添加 token 对象,然后这个 token 也会返回给调用方,这个 token 在移除屏障时需要用到。

通过以下方法可以移除这个消息屏障:


public void removeSyncBarrier(int token) {
    // Remove a sync barrier token from the queue.
    // If the queue is no longer stalled by a barrier then wake it.
    synchronized (this) {
        Message prev = null;
        Message p = mMessages;
        while (p != null && (p.target != null || p.arg1 != token)) {
            prev = p;
            p = p.next;
        }
        if (p == null) {
            throw new IllegalStateException("The specified message queue synchronization "
                    + " barrier token has not been posted or has already been removed.");
        }
        final boolean needWake;
        if (prev != null) {
            prev.next = p.next;
            needWake = false;
        } else {
            mMessages = p.next;
            needWake = mMessages == null || mMessages.target != null;
        }
        p.recycleUnchecked();

        // If the loop is quitting then it is already awake.
        // We can assume mPtr != 0 when mQuitting is false.
        if (needWake && !mQuitting) {
            nativeWake(mPtr);
        }
    }
}

请求绘制最终会到 ViewRootImpl#scheduleTraversals() 方法中,在这个方法中就会添加一个屏障:


void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        notifyRendererOfFramePending();
        pokeDrawLockIfNeeded();
    }
}

Choregorapher 收到 Vsync 信号后会发送一个 Handler 的异步 Message:

public void onVsync(long timestampNanos, long physicalDisplayId, int frame,
        VsyncEventData vsyncEventData) {
        // ..
        Message msg = Message.obtain(mHandler, this);
        msg.setAsynchronous(true);
        mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
       // ..
}

在绘制完成后会调用 ViewRootImpl#unscheduleTraversals() 方法移除这个屏障:

    void unscheduleTraversals() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
            mChoreographer.removeCallbacks(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        }
    }
IdleHandler

IdleHandler 就是在 next() 方法获取不到消息时会执行一次,next() 方法调用一次最多只能执行一次 IdleHandler,执行 IdleHandler 的时候就表示当前的 Looper 比较空闲,IdleHandler 的数量最多为 4 个 (在 ActivityonDestroy() 回调就是在 IdleHandler 中执行的,可能写这部分代码的人,认为 onDestroy() 相对其他的生命周期没有那么重要,但是由于 IdleHandler 的特性也会引发一些问题,后续有时间会分析这个问题) 。
可以通过以下方法添加 IdleHandler

    public void addIdleHandler(@NonNull IdleHandler handler) {
        if (handler == null) {
            throw new NullPointerException("Can't add a null IdleHandler");
        }
        synchronized (this) {
            mIdleHandlers.add(handler);
        }
    }

epoll 在 Handler 中的工作方式

MessageQueue 的构造函数中会通过 Native 方法 nativeInit() 初始化 epoll,初始化流程会先通过 epoll_create() 方法创建一个 EpollFd,然后构建一个管道的 fd 用于 epoll 来监听它的读写状态,这个 fd 被命名为 WakeFd,然后通过 epoll_ctl() 方法将 WakeFd 添加到 EpollFd 中,以供后续通过 EpollFd 能够监听 WakeFd 的状态。
MessageQueuenext() 方法中会通过 Native 方法 nativePollOnce() 方法去监听上面所提到 WakeFd 的状态,Native 层是通过 epoll_wait() 方法去监听状态,这个方法会等到超时或者 WakeFd 有数据写入就会返回。
MessageQueueenqueueMessage() 方法中插入 Message 后,会判断是否需要唤醒 next() 中的 nativePollOnce() 方法,如果需要唤醒就会调用 Native 方法 nativeWake() 方法,Native 层就会向 WakeFd 中写入一个数字 1,然后 nativePollOnce() 间听到这个 WakeFd 写状态后就会返回,也就解除 next() 方法的阻塞去读取新的 Message

最后聊一聊主线程

在 Android Framework 中有两个主要的主线程 Handler,一个是 ActivityThread 中的 H,它主要来处理四大组建的各种生命周期;还有一个是 Choreographer 中的 FrameHandler,它主要负责绘制,动画,输入等操作。这两个 Handler 负责的工作和用户体验都极为密切,主线程也可以说非常忙。
在项目中我经常发现很多的代码都会在主线程执行,明明有的操作不需要主线程执行。比如说在一个网络请求的通用方法中,在网络完成的回调中就会主动切换到主线程,明明很多地方的代码不需要用到主线程,我个人的观点是一定需要切换主线程时再切换,在一定程度上能够缓解应用卡顿的问题。

如果你还没有掌握Framework,现在想要在最短的时间里吃透它,可以参考一下《Android Framework核心知识点》,里面内容包含了:Init、Zygote、SystemServer、Binder、Handler、AMS、PMS、Launcher……等知识点记录。

《Framework 核心知识点汇总手册》:https://qr18.cn/AQpN4J

Handler 机制实现原理部分:
1.宏观理论分析与Message源码分析
2.MessageQueue的源码分析
3.Looper的源码分析
4.handler的源码分析
5.总结

Binder 原理:
1.学习Binder前必须要了解的知识点
2.ServiceManager中的Binder机制
3.系统服务的注册过程
4.ServiceManager的启动过程
5.系统服务的获取过程
6.Java Binder的初始化
7.Java Binder中系统服务的注册过程

Zygote :

  1. Android系统的启动过程及Zygote的启动过程
  2. 应用进程的启动过程

AMS源码分析 :

  1. Activity生命周期管理
  2. onActivityResult执行过程
  3. AMS中Activity栈管理详解

深入PMS源码:

1.PMS的启动过程和执行流程
2.APK的安装和卸载源码分析
3.PMS中intent-filter的匹配架构

WMS:
1.WMS的诞生
2.WMS的重要成员和Window的添加过程
3.Window的删除过程

《Android Framework学习手册》:https://qr18.cn/AQpN4J

  1. 开机Init 进程
  2. 开机启动 Zygote 进程
  3. 开机启动 SystemServer 进程
  4. Binder 驱动
  5. AMS 的启动过程
  6. PMS 的启动过程
  7. Launcher 的启动过程
  8. Android 四大组件
  9. Android 系统服务 - Input 事件的分发过程
  10. Android 底层渲染 - 屏幕刷新机制源码分析
  11. Android 源码分析实战

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

如何进行网站测试

随着市场和技术的快速发展&#xff0c;产品需要不断更新和改进以保持竞争力&#xff0c;如果产品停滞不前&#xff0c;很可能会被市场淘汰。通过持续发展&#xff0c;企业可以不断优化产品&#xff0c;提高用户体验&#xff0c;从而赢得市场份额和客户忠诚度。而数通在激烈的市…

计算机毕业设计项目选题推荐(免费领源码)Java+springboot+Mysql停车微信小程序小程序92714

摘 要 在信息飞速发展的今天&#xff0c;网络已成为人们重要的信息交流平台。每天都有大量的农产品需要通过网络发布&#xff0c;为此&#xff0c;本人开发了一个基于springboot停车微信小程序小程序。 对于本停车微信小程序的设计来说&#xff0c;它主要是采用后台采用java语…

Vue+OpenLayers 创建地图并显示鼠标所在经纬度

1、效果 2、创建地图 本文用的是高德地图 页面 <div class"map" id"map"></div><div id"mouse-position" class"position_coordinate"></div>初始化地图 var gaodeLayer new TileLayer({title: "高德地…

PDF有限制密码,不能复制怎么办?

大家现在接触PDF文件越来越多&#xff0c;有的时候在网上下载的PDF文件打开之后&#xff0c;发现选中文字之后无法复制。甚至其他功能也都无法使用&#xff0c;这是怎么回事&#xff1f;该怎么办&#xff1f; 当我们发现文件打开之后&#xff0c;编辑功能无法使用&#xff0c;很…

数据库数据迁移常见方式

数据库数据迁移常见方式 数据库数据迁移常见方式1、通过sql2、通过数据迁移工具3、云服务进行数据迁移什么是DRS服务如何使用DRS服务DRS云服务可以干什么 数据库数据迁移常见方式 1、通过sql 批量导入sql insert into tableName select * 2、通过数据迁移工具 在数据库里面…

19.9 Boost Asio 同步字典传输

这里所代指的字典是Python中的样子&#xff0c;本节内容我们将通过使用Boost中自带的Tokenizer分词器实现对特定字符串的切割功能&#xff0c;使用Boost Tokenizer&#xff0c;可以通过构建一个分隔符或正则表达式的实例来初始化tokenizer。然后&#xff0c;可以使用该实例对输…

网络工程师回顾学习(第一部分)

根据书本目录&#xff0c;写下需要记忆的地方&#xff1a; 参考之前的笔记&#xff1a; 网络工程师回答问题_one day321的博客-CSDN博客 重构第一部分需要记忆的&#xff1a; 第一章&#xff1a;计算机网络概论 计算机网络的定义和分类&#xff1a;计算机网络是指将地理位…

Azure - 机器学习:自动化机器学习中计算机视觉任务的超参数

Azure Machine Learning借助对计算机视觉任务的支持&#xff0c;可以控制模型算法和扫描超参数。 这些模型算法和超参数将作为参数空间传入以进行扫描。 关注TechLead&#xff0c;分享AI全维度知识。作者拥有10年互联网服务架构、AI产品研发经验、团队管理经验&#xff0c;同济…

MYSQL函数,一篇文章看完!

做程序员的谁会离得开数据库呢&#xff1f;今天就来分享一下我整理的MySQL的常用函数&#xff0c;基本上囊括了平时要用的函数&#xff0c;它们已经陪我走过了不少年头了&#xff0c;风里来雨里去&#xff0c;缝缝补补又几年&#xff0c;希望能帮到你们&#xff01; 如果数据库…

UltraEdit2024免费版文本编辑器

我们必须承认软件员使用的编辑器或代码编辑器是一款强大 IDE 的重要组成部分&#xff0c;它是任何 IDE 的核心基础。用户量向我们证明了UEStudio 基于著名的 UltraEdit 进行构建&#xff0c;同样&#xff0c;软件的主干非常成熟和稳定&#xff0c;并且已经被证实成为文本和软件…

Python 标准库 subprocess 模块详解

1. Subprocess模块介绍 1.1 基本功能 subprocess 模块&#xff0c;允许生成新的进程执行命令行指令&#xff0c;python程序&#xff0c;以及其它语言编写的应用程序, 如 java, c,rust 应用等。subprocess可连接多个进程的输入、输出、错误管道&#xff0c;并且获取它们的返回…

龙芯loongarch64服务器编译安装scipy

前言 根据我之前的文章介绍&#xff0c;龙芯loongarch64服务器中的很多python依赖包安装有问题&#xff0c;发现其中安装的"scikit-learn"就无法正常使用&#xff0c;所有这里在 pip3 install scikit-learn -U -i https://pypi.tuna.tsinghua.edu.cn/simple 的时候发…

酷开系统,让这个秋天更有温度

在这个秋意渐浓的季节&#xff0c;你是不是也在寻找一种方式&#xff0c;让这个秋天变得更加温暖和充满活力&#xff1f;随着科技的不断发展&#xff0c;智能电视已经成为家庭娱乐的重要载体&#xff0c;酷开系统&#xff0c;作为智能电视行业的佼佼者&#xff0c;不仅提供了海…

Linux常用命令——cal命令

在线Linux命令查询工具 cal 显示当前日历或指定日期的日历 补充说明 cal命令用于显示当前日历&#xff0c;或者指定日期的日历。 语法 cal(选项)(参数)选项 -l&#xff1a;显示单月输出&#xff1b; -3&#xff1a;显示临近三个月的日历&#xff1b; -s&#xff1a;将星…

client-go controller-runtime kubebuilder

背景 这半年一直做k8s相关的工作&#xff0c;一直接触client-go controller-runtime kubebuilder&#xff0c;但是很少有文章将这三个的区别说明白&#xff0c;直接用框架是简单&#xff0c;但是出了问题就是黑盒&#xff0c;这不符合我的理念&#xff0c;所以这篇文章从头说起…

【Java 进阶篇】Java Filter 过滤器链详解

过滤器&#xff08;Filter&#xff09;是 Java Web 应用中重要的组件之一&#xff0c;它用于在请求到达 Servlet 之前或响应返回客户端之前对请求和响应进行处理。在实际开发中&#xff0c;我们可能会使用多个过滤器来完成不同的任务&#xff0c;这就引出了过滤器链的概念。本文…

Qt 自定义分页控件

目录 前言1、功能描述2、代码实现2.1 ui文件2.1 头文件2.2 源码文件2.3 设计思路 4、示例5、总结 前言 在应用程序开发时经常会遇到数据分页的需求&#xff0c;每一页展示特定数量的数据&#xff0c;通过点击按钮翻页或者输入页码跳转到指定页。 本文介绍一个自定义分页控件&a…

【Spring】静态代理

例子&#xff1a; 租房子 角色&#xff1a; 我 &#xff08;I ) 中介( Proxy ) 房东( host ) Rent 接口 package org.example;public interface Rent {void rent(); }房东 package org.example;public class Host implements Rent{Overridepublic void rent() …

立体相机标定

相机成像过程中涉及的4个坐标系&#xff1a; 1、世界坐标系&#xff1a;由用户定义的三维世界坐标系&#xff0c;描述物体和相机在真实世界中的位置&#xff0c;原点可以任意选择。 2、相机坐标系&#xff1a;以相机的光心为坐标原点&#xff0c;X轴和Y轴平行于图像坐标系的X轴…

uniapp实现在线PDF文件预览

下载pdf文件放在static文件夹下 bug&#xff1a;hbuildX创建的项目pdf文件夹可以放在根目录下面&#xff0c;但是cli创建的项目无法预览&#xff0c;只能放在static下面 按钮跳转预览页面 <button click"toPdf">pdf</button>methods: {toPdf() {uni.…
最新文章