android 消息机制详解

栏目: 后端 · 发布时间: 4年前

内容简介:相信于此,绝大多数同学都会回答消息机制是android 为了线程间通信而引入的工具。可以轻松的将一个任务切换到handler所在线程执行。android开发规范有规定,不允许于子线程更新ui,这样会触发异常;我们平时使用handler主要都是将子线程切换到主线程中去执行;因此从本质上来来说,Handler并不是专门用于更新UI的,它只是常被开发者用来更新UI。A:因为Android的UI线程是非A:我们都知道在java中,线程存在以下几种基本状态:

相信于此,绝大多数同学都会回答消息机制是android 为了线程间通信而引入的工具。可以轻松的将一个任务切换到handler所在线程执行。android开发规范有规定,不允许于子线程更新ui,这样会触发异常;我们平时使用handler主要都是将子线程切换到主线程中去执行;因此从本质上来来说,Handler并不是专门用于更新UI的,它只是常被开发者用来更新UI。

Q?为何不能在主线程外更新ui呢?

A:因为Android的UI线程是非 线程安全 的,应用更新UI,是调用 invalidate() 方法来实现界面的重绘,而 invalidate() 方法是 非线程安全 的,也就是说当我们 在非UI线程来更新UI时,可能会有其他的线程或UI线程也在更新UI,这就会导致界面更新的不同步 。因此我们不能在非UI主线程中做更新UI的操作。也就是说我们在使用Android中的线程时,要保证: 更新ui都在UI主线程执行.

Q:那为何不将需要更新ui的操作放在UI线程执行呢?

A:我们都知道在 java 中,线程存在以下几种基本状态: 创建 , 就绪运行阻塞 , 死亡 。我们的应用启动后,所有的交互都是在 UI 线程完成的;如果在 UI 执行延时操作,如常见的 网络请求UI 线程就会进入 阻塞 状态;此时用户就无法响应任何操作了;如果此过程超过5秒,就会让程序处于 ANR(application not response) ,这时用户就可能想要和你的应用说声 gg 了。

Q:Android提供了哪几种线程间通信方式?

A: AsyncTask? , Handler 。为什么 AsynTask 打了个 ? 呢,我们可以简单看下 AsynTask 源码,他内部也是接住handler来进行线程间通信的。

Q:MessageQueue存在Targer对象的消息,那和我们正常流程中,由handler传递的消息有什么出入呢?

A:其实平时我们使用的 Message ,都是通过 Handler 发送的,有一些系统消息,他们会直接通过调用 MessageQueue 发送一个屏障消息,这类消息没有 Target ,然后配合 Handler 发送异步消息来使用;当 MessageQueue 读取到屏障消息后,他们会直接在链表中找到最近的 异步消息 ,直接执行。

feature-要素

  • Message(消息单元)定义一个可以发送到 Handler 的消息;它定义了 消息Id ,两个额为的int字段和一个额外的 object字段 (消息处理对象),它们可以不被初始化;虽然它的构造方法是public,但是还是建议我们通过obtain系列函数进行定义。

  • MessageQueue(消息队列)存放所有发送的消息队列,单链表结构,供Looper从中读取数据;延时消息是怎么存取的,这个很有趣;

  • Looper(消息读取者)永动机;其中有个死循环函数 Loop() ,不断读取 MessageQueue 中的消息,交给目标处理;问题来了,既然是个死循环,那不是始终会阻塞 Looper 所在线程吗。这又是如何解决的。

  • Handler(消息分发以及处理者)通过 sendMessage 系列函数,会将 Message 传入 MessageQueue 中; Looper.loop() 读取到消息传递给 Handler 处理。

desc

  1. handler 创建前, Looper.loop() 执行前;需要保证当前线程 Looper 有创建,而这个保证即 Looper.prepare() ;主线程由于在1ActivityThread1创建时,已经做过,所以无需执行;
  2. Looper.loop() 中有一个死循环,所以线程资源不会释放;在线程运行结束时调用 MessageQueue 中的 quit 函数,我们才能释放资源;
  3. Java 中,所有非静态成员变量会持有当前对象的引用(不然你又是怎么引用外部类的各种成员变量和函数等);那样我们在 Activity 中通过 new Handler() , 创建的对象会持有当前页面的引用;而我们发送的每个消息不能保证是立即执行,以及迅速执行结束的, handler.sendEmptyMessageDelayed ;消息是会持有 handler 做为他的 target ,那在这个 message 在通过 msg.target.dispatchMessage(msg); 会一直被持有;这样会导致 messageQueue->message->handler->activity|fragment ;在页面被销毁,声明周期执行到 desatory 时, activity 不会得到释放,从而 内存泄漏handler 得到消息处理时,如果当前页面已经被销毁,执行 Ui 更新,又会导致难以预料的问题。
  4. 针对 3 所提的我们可以按以下两种处理: 1:页面 destory 销毁时,调用 handler.removeCallbacksAndMessages(null); 2:通过软引用创建静态Handler对象;

流程解析

android handler流程分析晚上有很多资料;我们这儿简单介绍下:

  • Looper , MessageQueue 就绪;调用 Looper.prepare() ,其间会向 Looper 静态线程变量 sThreadLocal 插入一个当前线程的 Looper ;在调用 Looper 构造函数时,我们会初始化 MessageQueue ,并将 mThread 设置为当前 Thread.currentThread();

  • Looper.prepare() 代码块如下:

    public static void prepare() {
            prepare(true);
        }
    
    private static void prepare(boolean quitAllowed) {
        	//sThreadLocal->ThreadLocal对象,里面封装了一个map逻辑,key是线程hash值;static 类变量
           if (sThreadLocal.get() != null) {//不允许多次prepare
                throw new RuntimeException("Only one Looper may be created per thread");
           }
           sThreadLocal.set(new Looper(quitAllowed));//设置当前线程的Looper
     }
    复制代码

    Looper 构造函数,以及 MessageQueue 构造函数如下:

    private Looper(boolean quitAllowed) {
            mQueue = new MessageQueue(quitAllowed); //初始化消息池
            mThread = Thread.currentThread();
     }
    //是否允许退出
    MessageQueue(boolean quitAllowed) {
            mQuitAllowed = quitAllowed; 
            mPtr = nativeInit(); //线程id
      }
    复制代码
  • 接下来我们看下数据插入

    sendMessage(Message msg)
    sendEmptyMessage(int what)
    sendEmptyMessageDelayed(int what, long delayMillis)
    sendEmptyMessageAtTime(int what, long uptimeMillis)
    sendMessageDelayed(Message msg, long delayMillis)
    sendMessageAtTime(Message msg, long uptimeMillis)
    sendMessageAtFrontOfQueue(Message msg)
    

    这以上七个方法,可以通过handler向handler所在线程发送消息;其中 1,2,3,4,5 都是调用方法 6 进行执行的;其中方法 6 中的 uptimeMillis 取的是系统非休眠时间 SystemClock.uptimeMillis()

    我们接下来看下 sendMessageAtTime(Message msg, long uptimeMillis) :

    public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
        MessageQueue queue = mQueue; //将线程变量Looper中的queue取出使用
        if (queue == null) { //queue判空,其实创建handler时,也是必须要Looper初始化结束;queue创建后的
            RuntimeException e = new RuntimeException(
                    this + " sendMessageAtTime() called with no mQueue");
            Log.w("Looper", e.getMessage(), e);
            return false;
        }
        return enqueueMessage(queue, msg, uptimeMillis);//简单判断,交给enqueueMessage函数执行;
    }
    复制代码

    7.sendMessageAtFrontOfQueue(Message msg) 调用的函数如下:

    public final boolean sendMessageAtFrontOfQueue(Message msg) {
            MessageQueue queue = mQueue;
            if (queue == null) {
                RuntimeException e = new RuntimeException(
                    this + " sendMessageAtTime() called with no mQueue");
                Log.w("Looper", e.getMessage(), e);
                return false;
            }
            return enqueueMessage(queue, msg, 0);//简单判断,交给enqueueMessage函数执行;设置执行时间0
        }
    复制代码

    据此,我们发现所有消息的发送都是通过 MessageQueueenqueueMessage(Message msg, long when) 方法;

  • 我们来看下 enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis)

    private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
        msg.target = this; //将msg的target设置为当前handler;这儿可以看出msg和handler是 n:1的关系
        if (mAsynchronous) {//handler是否是异步?默认false;将值赋予msg
            msg.setAsynchronous(true);
        }
        return queue.enqueueMessage(msg, uptimeMillis);//可以看到,此处最终调用了eqeue的enqueueMessage方法
    }
    复制代码

    那我们看下 enqueueMessage 函数(handler的消息基本都是通过该函数放入线程 MessageQueue 中):

    boolean enqueueMessage(Message msg, long when) {
        if (msg.target == null) {//handler不能为空
            throw new IllegalArgumentException("Message must have a target.");
        }
        if (msg.isInUse()) {//消息是否在被使用处理
            throw new IllegalStateException(msg + " This message is already in use.");
        }
    
        synchronized (this) {//同步锁
            if (mQuitting) {//mQuitting;子线程消息池我们在线程即将结束时,调用这个mQuitting退出;之后发送的消息都是不会被收入消息池的;所以如果遇到消息没有发送成功,我们可能需要判断是不是looper已经退出了;
                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;
                //表头消息替换为放入的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.
                //入队列中,默认不唤醒,仅当头部msg是屏障消息,当前msg是异步消息
                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插入对应节点
                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;
    }
    复制代码
  • 于此我们所有的消息入栈已经看完了;那消息是怎么获取的呢; mBlocked 是不是真的代表线程阻塞呢?根据前面的图形介绍,我们知道, Looper 中有一个 loop 函数,他是一个死循环,负责向 MessageQueue 读取数据,接下来我们来看下这个函数;

    /**
         * Run the message queue in this thread. Be sure to call
         * {@link #quit()} to end the loop. 在线程结束时,要调用looper.quit()退出
         */
        public static void loop() {
            final Looper me = myLooper(); //静态方法,获取当前线程的Looper;两个工作线程进行通信,需要先在补获线程调用prepare(),并在其run()结束时,调用quit()
            if (me == null) {
                throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
            }
            final MessageQueue queue = me.mQueue; //获取mQueue
    
            // Make sure the identity of this thread is that of the local process,
            // and keep track of what that identity token actually is.
            Binder.clearCallingIdentity();  //清空远程调用端的uid和pid,用当前本地进程的uid和pid替代
            final long ident = Binder.clearCallingIdentity();
    
            // Allow overriding a threshold with a system prop. e.g.
            // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
            final int thresholdOverride =
                    SystemProperties.getInt("log.looper."
                            + Process.myUid() + "."
                            + Thread.currentThread().getName()
                            + ".slow", 0);
    
            boolean slowDeliveryDetected = false;
    
            for (;;) {
                Message msg = queue.next(); // might block 获取消息可能阻塞线程;我们先分析它,后面的代码下面分析
               ……
            }
        }
    复制代码

    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) { //在调用quit结束loop后,又一次尝试调用prepare后,此时ptr会为0,不支持
                return null;
            }
    
            int pendingIdleHandlerCount = -1; // -1 only during first iteration 默认pendingIdleHandler为0
            int nextPollTimeoutMillis = 0; //需要阻塞时间,-1表示无限阻塞,直到消息入栈调用nativeWake唤醒
            for (;;) {
                if (nextPollTimeoutMillis != 0) {//time不为0存在阻塞
                    Binder.flushPendingCommands();  //native方法,看注释是配合线程长时间阻塞使用,用于释放任何的挂起对象
                }
    
                nativePollOnce(ptr, nextPollTimeoutMillis);//线程阻塞,time阻塞时长
    			
                //同步锁
                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) {
                        //when比当前时间大;需要阻塞
                        if (now < msg.when) {
                            // Next message is not ready.  Set a timeout to wake up when it is ready.
                            nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);//计算消息的延时时间
                        } else {
                            //返回这个将要执行的消息;将mBlocked阻塞置false;将当前message置为执行消息后一个
                            // 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();
                        dispose();
                        return null;//返回null作为next()执行结果,注意,此时Looper.loop()也会执行结束
                    }       
                    //idleHandlers->idleHandler是指一个线程当前没有需要立即执行的消息,(延时执行or无消息)时,会执行的一个callback;根据上面的分析,只有在next()执行,且没有需要返回消息时执行         
                    if (pendingIdleHandlerCount < 0 
                            && (mMessages == null || now < mMessages.when)) {//延时执行or无消息
                        pendingIdleHandlerCount = mIdleHandlers.size(); //只有调用addIdleHandler加入idle时,count才会增加
                    }
                    //默认0;无idle时,mBlocked阻塞置为true,执行循环 for (;;) {}内部内容
                    if (pendingIdleHandlerCount <= 0) {
                        // 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 {
                        keep = idler.queueIdle();//根据queueIdle返回值,决定是否需要执行后移除该idle
                    } catch (Throwable t) {
                        Log.wtf(TAG, "IdleHandler threw exception", t);
                    }
    
                    if (!keep) {
                        synchronized (this) {
                            mIdleHandlers.remove(idler);
                        }
                    }
                }
    			
                //执行完PendingIdleHandler后,我门将count置为0,不再执行他
                // Reset the idle handler count to 0 so we do not run them again.
                pendingIdleHandlerCount = 0;
    			//执行完idle后,可能有消息准备就绪,我们重新计算阻塞时间
                // 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;
            }
        }
    复制代码

    总结一下, MessageQueue 不断获取待执行消息,并可能阻塞线程(没有message or 待执行 messagewhen 比当前时间晚);而 MessageQueue 提供了一个 Idle机制 ,用于在当前线程没有由于没有待执行 Message 或者 延时Message 时执行,而 addIdleHandler 就是用于添加 Idle

    我们再回头看下 Looper.loop() :

    for (;;) {
                Message msg = queue.next(); // might block 刚刚分析上文,当前消息是延时消息或者消息队列为空时,会进行阻塞
                if (msg == null) { //没有消息退出循环,loop结束工作 ; next控制 ;即Mq执行quit退出后,不在执行任何消息
                    // No message indicates that the message queue is quitting.
                    return;
                }
    
                // 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);
                }
    			//Trace用于追踪一个Message执行的;可以结合TraceView等 工具 查看,具体请百度吧;而我们的Ui线程所有的ui绘制,事件流执行,等都属于一个消息,可以通过Trace进行跟踪;
                final long traceTag = me.mTraceTag;
                long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
                long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
                if (thresholdOverride > 0) {
                    slowDispatchThresholdMs = thresholdOverride;
                    slowDeliveryThresholdMs = thresholdOverride;
                }
                final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
                final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);
    
                final boolean needStartTime = logSlowDelivery || logSlowDispatch;
                final boolean needEndTime = logSlowDispatch;
    		
                if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
                    Trace.traceBegin(traceTag, msg.target.getTraceName(msg));//跟踪起始
                }
    
                final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
                final long dispatchEnd;
                try {
                    msg.target.dispatchMessage(msg); //分发消息
                    dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0; //记录执行结束时间
                } finally {
                    if (traceTag != 0) {
                        Trace.traceEnd(traceTag);//跟踪结束
                    }
                }
         		//Slow?没研究,但也是打印相关日志信息的。。
                if (logSlowDelivery) {
                    if (slowDeliveryDetected) {
                        if ((dispatchStart - msg.when) <= 10) {
                            Slog.w(TAG, "Drained");
                            slowDeliveryDetected = false;
                        }
                    } else {
                        if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
                                msg)) {
                            // Once we write a slow delivery log, suppress until the queue drains.
                            slowDeliveryDetected = true;
                        }
                    }
                }
                if (logSlowDispatch) {
                    showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
                }
    
                if (logging != null) {
                    logging.println("<<<<< Finished to " + msg.target + " " + msg.callback); //打印结束时间
                }
    
                // Make sure that during the course of dispatching the
                // identity of the thread wasn't corrupted.
                final long newIdent = Binder.clearCallingIdentity();
                if (ident != newIdent) {
                    Log.wtf(TAG, "Thread identity changed from 0x"
                            + Long.toHexString(ident) + " to 0x"
                            + Long.toHexString(newIdent) + " while dispatching to "
                            + msg.target.getClass().getName() + " "
                            + msg.callback + " what=" + msg.what);
                }
    	
                msg.recycleUnchecked();//msg结合obtion()实现对象复用
            }
    复制代码

    结合代码来看 Looper.loop() 所做的事情不多,主要都是用于记录分析 Message 信息的:

  • 开启一个死循环,将消息读取交给 MessageQUeuenext() 函数,该函数可能导致线程阻塞;

  • 提供一个 Printer 接口,记录打印每个 Message 的执行开始和结束信息;

  • 提供的 Trace 函数用来记录每个消息的处理信息;

  • 通过 msg.target.dispatchMessage(msg) 执行消息

  • 通过 msg.recycleUnchecked() 回收消息,使得 Message 消息池得到复用; Message 是一个链表结构,提供了 Message.obtion() 方法,用于不断的取链表头对象;在表头空时新建;消息执行完调用的 recycleUnchecked 会将 Message 相关消息情况,插入链表头

    至此我们对 Android 的消息机制发送和读取有了一个完整的了解:下面附上一个简单的流程图(md的流程图绘制,真心累啊。)

对于一个消息创建流程,加入消息队列, MessageQueue 简单通过 Mq 代表了:

android 消息机制详解

接下来是消息读取的流程图:

android 消息机制详解

上面流程图中有涉及到一个新的消息概念 屏障消息(无target的消息)

我们向消息队列 MessageQueue 发送一个屏障消息,然后再发送一个异步消息;在我们读取到这个屏障消息的时候,我们会找到链表后的第一个异步消息;这样就能快速执行该异步消息了;

系统有一个 postSyncBarrier() 用来发送屏障消息,但是被隐藏了;我们可以反射调用或者直接向 MessageQueue 表头反射插入一个 Message ,但是不建议这样做;

发送异步消息可以通过:创建 Handler 时,传入异步参数:

public Handler(boolean async);
public Handler(Callback callback, boolean async);
public Handler(Looper looper, Callback callback, boolean async);
复制代码

这样就能发送屏障消息和异步消息了;

在系统源码 ViewRootImpl.scheduleTraversals 中,为了更快响应 UI刷新事件时

void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        //设置同步障碍,确保mTraversalRunnable优先被执行
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        //内部通过Handler发送了一个异步消息
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        if (!mUnbufferedInputDispatch) {
            scheduleConsumeBatchedInput();
        }
        notifyRendererOfFramePending();
        pokeDrawLockIfNeeded();
    }
复制代码

mTraversalRunnable 调用了 performTraversals 执行 measure、layout、draw

为了让 mTraversalRunnable 尽快被执行,在发消息之前调用 MessageQueue.postSyncBarrier 设置了同步屏障


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

物联网导论(第2版)

物联网导论(第2版)

刘云浩 / 科学出版社 / 2013-8 / 45.00元

物联网是一个基于互联网、传统电信网等信息承载体,让所有能够被独立寻址的普通物理对象实现互联互通的网络。它具有普通对象设备化、自治终端互联化和普适服务智能化三个重要特征。 《物联网工程专业系列教材:物联网导论(第2版)》从物联网的感知识别层、网络构建层、管理服务层和综合应用层这四层分别进行阐述,深入浅出地为读者拨开萦绕于物联网这个概念的重重迷雾,引领求知者渐渐步入物联网世界,帮助探索者把握第三......一起来看看 《物联网导论(第2版)》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具