内容简介:零零碎碎的东西总是记不长久,仅仅学习别人的文章也只是他人咀嚼后留下的残渣。无意中发现了这个前几天的那个面试弄得我一脸懵逼,现在有点空闲时间,仔细的探讨一下当时的问题。印象最深的就是关于 HandlerThread 与 AsyncTask 的问题。首先列一下探讨过程中想到的问题以及面试时问的问题。先简单讲讲这两个的源码吧,都挺简单的,并没有那么复杂。
零零碎碎的东西总是记不长久,仅仅学习别人的文章也只是他人咀嚼后留下的残渣。无意中发现了这个 每日一道面试题 ,想了想如果只是简单地去思考,那么不仅会收效甚微,甚至难一点的题目自己可能都懒得去想,坚持不下来。所以不如把每一次的思考、理解以及别人的见解记录下来。不仅加深自己的理解,更要激励自己坚持下去。
前言
前几天的那个面试弄得我一脸懵逼,现在有点空闲时间,仔细的探讨一下当时的问题。印象最深的就是关于 HandlerThread 与 AsyncTask 的问题。首先列一下探讨过程中想到的问题以及面试时问的问题。
- HandlerThread 与 AsyncTask 的大致实现原理
- HandlerThread 与 AsyncTask 完成耗时任务后会怎么样
- HandlerThread 与 AsyncTask 中的耗时事件处理是异步还是同步,可不可以变为另一种处理
- HandlerThread 与 AsyncTask 内存泄漏的可能性
- HandlerThread 与 AsyncTask 使用中的注意事项
- HandlerThread 与 AsyncTask 的实际应用场景
- 给定大量的耗时任务,耗时操作并不连续,耗时时长长短不一,用哪个
源码解析
先简单讲讲这两个的源码吧,都挺简单的,并没有那么复杂。 一下源码来自于 Android-28
HandlerThread
HandlerThread 实际上是 Thread+Looper+Handler 的一个简单封装。
HandlerThread 类继承 Thread 类,所以需要 start() 方法来开启线程。
HandlerThread handlerThread = new HandlerThread("workThread"); handlerThread.start(); Handler threadHandler = new Handler(handlerThread.getLooper()){ @Override public void handleMessage(Message msg) { super.handleMessage(msg); //根据不同的 message 类型处理不同的耗时任务 } }; 复制代码
这是简单的使用方法。创建 Handler 时需要传入 HandlerThread 中的 Looper 对象,这样 handlerMessage 方法就会在子线程中处理耗时任务。需要耗时任务可以通过 threadHandler.sendMessage() 发送消息,然后下 handlerMessage 方法中进行处理。
源码中最重要的就是一个重写的 run 方法。
@Override public void run() { mTid = Process.myTid(); Looper.prepare(); synchronized (this) { mLooper = Looper.myLooper(); notifyAll();//唤醒线程可以获取 Looper 对象了 } Process.setThreadPriority(mPriority); onLooperPrepared(); Looper.loop(); mTid = -1; } 复制代码
也很简单,在我们执行 handlerThread.start() 开启一个线程后,就会执行此方法。通过 Looper.prepare() 在此线程中创建一个 Looper 对象,然后通知其他线程可以获取 Looper 对象,设置线程优先级。onLooperPrepared() 是一个空的方法,我们可以重写此方法进行一些 Looper 开启 loop 循环之前的准备。
一切都准备好之后,就是通过 Handler 发送消息,然后在 handlerMessage() 中进行耗时操作。简单说明一下,Handler 类中的 handlerMessage() 方法在哪个线程中执行,是由 Handler 中的 Looper 对象所在的线程决定的,这是因为在 loop 循环中通过 msg.target.dispatchMessage()--->handleMessage() 间接地调用了 handlerMessage 方法,而 Looper.loop 是在子线程中执行的。具体可看 android 的消息机制详解--- 每日一道面试题(第 9 期)---谈谈 Handler 机制和原理
AsyncTask
AsyncTask 则是线程池与 Handler 的封装
一些基本使用就不在详细说明了,主要来看看源码。首先是构造方法,有三个。无参、Handler、Looper,前两个都会调用第三个构造方法。
public AsyncTask(@Nullable Looper callbackLooper) { mHandler = callbackLooper == null || callbackLooper == Looper.getMainLooper() ? getMainHandler() : new Handler(callbackLooper); mWorker = new WorkerRunnable<Params, Result>() { public Result call() throws Exception { mTaskInvoked.set(true); Result result = null; try { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); //noinspection unchecked result = doInBackground(mParams); Binder.flushPendingCommands(); } catch (Throwable tr) { mCancelled.set(true); throw tr; } finally { postResult(result); } return result; } }; mFuture = new FutureTask<Result>(mWorker) { @Override protected void done() { try { postResultIfNotInvoked(get()); } catch (InterruptedException e) { android.util.Log.w(LOG_TAG, e); } catch (ExecutionException e) { throw new RuntimeException("An error occurred while executing doInBackground()", e.getCause()); } catch (CancellationException e) { postResultIfNotInvoked(null); } } }; } 复制代码
有点长,但其实就三步操作。
- 初始化 mHandler。mHandler 是用来转换线程的。 当传入的 Looper 对象不为空且不是主线程的 Looper 时,就创建一个新的 Handler,否则就获取主线程的 handler。getMainHandler() 就是一个简单的创建新的 Handler 对象并将主线程的 Looper 对象传入进去的操作。
- 初始化 mWorker。mWorker 是一个实现了 Callable 接口的类的对象。初始化时重写了 call 方法,耗时任务 doINbackGround 被封装在这里面执行。
- 初始化 mFuture。mFuture 是 FutureTask 对象,是 Runnable 与 Future 的子类。将 mWorker 传入 mFuture 对象中。后面就是将此对象传入线程池中进行调度。
通常使用时通过 execute 方法开启任务,看看源码中干了什么。
public final AsyncTask<Params, Progress, Result> execute(Params... params) { return executeOnExecutor(sDefaultExecutor, params); } public final AsyncTask<Params, Progress, Result> executeOnExecutor(Executor exec, Params... params) { if (mStatus != Status.PENDING) { switch (mStatus) { case RUNNING: throw new IllegalStateException("Cannot execute task:" + " the task is already running."); case FINISHED: throw new IllegalStateException("Cannot execute task:" + " the task has already been executed " + "(a task can be executed only once)"); } } mStatus = Status.RUNNING; onPreExecute(); mWorker.mParams = params; exec.execute(mFuture); return this; } 复制代码
execute 中执行的是 executeOnExecutor 方法,并传入 sDefaultExecutor 与耗时任务需要的参数。首先是检查状态,mStatus 是一个枚举变量,有 PENDING、RUNNING、FINSHED 三种状态,这三种状态都是唯一的,按 PENDING---RUNNING---FINISHED 顺序,初始化对象时是 PENDING,在 executeOnExecutor 中变为 RUNNING,在 finish 方法中更新为 FINSHED 状态。因此可以看出 executor 方法只能在一个对象中执行一次,多次执行就会抛出异常。然后更新状态,调用 onPreExecute 方法,我们可以种重写此方法做些进行耗时操作前的准备。传入参数,然后就是 exec.execute 提交任务,也就是构造函数中包装好的 FutureTask 对象。
这个 exec 是成员变量 sDefaultExecutor,是 AsyncTask 内部定义的静态类,实现了 Executor 接口。
private static class SerialExecutor implements Executor { //双端队列,按照先进先出的原则储存 FutureTask 对象 final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>(); Runnable mActive; public synchronized void execute(final Runnable r) { //对传入的 mFuture 又进行了一次封装,以便于串行处理任务 mTasks.offer(new Runnable() { public void run() { try { r.run(); } finally { //执行完上一个耗时任务后,选取下一个任务 scheduleNext(); } } }); //选取任务 if (mActive == null) { scheduleNext(); } } protected synchronized void scheduleNext() { if ((mActive = mTasks.poll()) != null) { //由此可看出 SerialExecutor 只负责任务的串行处理,真正的耗时任务操作是交给 THREAD_POOL_EXECUTOR 线程池进行调度 THREAD_POOL_EXECUTOR.execute(mActive); } } } 复制代码
这是一个静态类,也就是说所有的耗时任务都要经过此类进行串行处理。SerialExecutor 就是为了使耗时任务能够串行的被处理才存在的,真正处理耗时任务的则是 THREAD_POOL_EXECUTOR 线程池。
//获得没有睡眠的 CPU 数量 private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); // We want at least 2 threads and at most 4 threads in the core pool, // preferring to have 1 less than the CPU count to avoid saturating // the CPU with background work private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4)); private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1; private static final int KEEP_ALIVE_SECONDS = 30; private static final ThreadFactory sThreadFactory = new ThreadFactory() { private final AtomicInteger mCount = new AtomicInteger(1);//cas 操作的 int 变量 public Thread newThread(Runnable r) { //记录创建线程的数量 return new Thread(r, "AsyncTask #" + mCount.getAndIncrement()); } }; private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<Runnable>(128); public static final Executor THREAD_POOL_EXECUTOR; static { ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, sPoolWorkQueue, sThreadFactory); //设置核心线程池也受设置的存活时间的影响 threadPoolExecutor.allowCoreThreadTimeOut(true); THREAD_POOL_EXECUTOR = threadPoolExecutor; } 复制代码
以上就是创建了一个新的线程池,并赋值给静态变量 THREAD_POOL_EXECUTOR,这部分操作是在静态代码块中进行的,也就是说只会在被类加载的时候执行一次。所有的耗时任务都是在这仅有的一个线程池中执行任务。简单说下这个线程池中的参数。
- CORE_POOL_SIZE:核心线程池数量,这个定义的有点复杂,官方解释说,总是希望核心线程数量在 2-4 之间,并且更希望比正在工作的 CPU 数量少 1.
- MAXIMUM_POOL_SIZE:所存在的最大线程数量,也就是核心线程与非核心线程之和的数量。默认为正在工作的 CPU 数量的 2 倍+1。
- KEEP_ALIVE_SECONDS:非核心线程完成任务后保持存活的时间,超时将被销毁。如果设置了 allowCoreThreadTimeOut(true) 属性,则核心线程也受此约束。默认为 30
- TimeUnit.SECONDS:上一个属性的单位,这里是秒。
- sPoolWorkQueue:储存耗时任务的队列,这里用的 LinkBlockingQueue,基于链表实现的阻塞队列,并将储存数量控制在了 128 个。
- sThreadFactory:线程工厂,为线程池提供新线程的创建。ThreadFactory 是一个接口,里面只有一个 newThread 方法。 默认为 DefaultThreadFactory 类。
剩下的就是线程池中调度 mFuture 执行耗时任务,执行其中的 mFuture 中 Callable 接口的 call 方法。其实就是上面说到的构造方法中初始化的 mWorker,其中对 call 方法进行了重写,在来了解下。
mWorker = new WorkerRunnable<Params, Result>() { public Result call() throws Exception { //标记耗时任务已被执行 mTaskInvoked.set(true); Result result = null; try { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); //noinspection unchecked result = doInBackground(mParams); Binder.flushPendingCommands(); } catch (Throwable tr) { //若发生异常则设置任务为取消状态 mCancelled.set(true); throw tr; } finally { //无论如何,处理结果 postResult(result); } return result; } }; 复制代码
可以看出,在进行耗时操作后,无论是处理完,还有发生异常,都要 postResult() 方法进行收尾。
private Result postResult(Result result) { @SuppressWarnings("unchecked") Message message = getHandler().obtainMessage(MESSAGE_POST_RESULT, new AsyncTaskResult<Result>(this, result)); message.sendToTarget(); return result; } 复制代码
用 Handler 包装了一个信息,发送了出去。标记为 MESSAGE_POST_RESULT,意思就是耗时任务的结果。这个 Handler,就是构造函数中初始化的那个 Handler 对象,只不过在通过 getMainHandler 中是用自定义的静态内部 Handler 类进行了包装。
private static Handler getMainHandler() { synchronized (AsyncTask.class) { if (sHandler == null) { sHandler = new InternalHandler(Looper.getMainLooper()); } return sHandler; } } private static class InternalHandler extends Handler { public InternalHandler(Looper looper) { super(looper); } @SuppressWarnings({"unchecked", "RawUseOfParameterizedType"}) @Override public void handleMessage(Message msg) { //AsyncTask 的静态内部类,方便传递结果数据与对应的 AsyncTask 对象 AsyncTaskResult<?> result = (AsyncTaskResult<?>) msg.obj; switch (msg.what) { case MESSAGE_POST_RESULT: // There is only one result result.mTask.finish(result.mData[0]); break; case MESSAGE_POST_PROGRESS: result.mTask.onProgressUpdate(result.mData); break; } } } 复制代码
发送的消息就在这里被处理了。如果是 MESSAGE_POST_RESULT,就调用 finish 方法;如果是 MESSAGE_POST_PROGRESS,就是 onProgressUpdate(result.mData) 方法,也就是我们可以用来重写更新进度的操作。MESSAGE_POST_PROGRESS 类消息只有在你调用 publishProgress 方法时才会被调用。
private void finish(Result result) { if (isCancelled()) { onCancelled(result); } else { onPostExecute(result); } mStatus = Status.FINISHED; } 复制代码
finish 方法中会根据 mCancel 的状态决定调用 onCancelled(result) 还是 onPostExecute(result),也就是说这两个只会调用其中一个方法,这两个方法也是我们在使用 AsyncTask 需要重写的方法。mCanael 我们可以通过调用 cancel() 方法改变状态。
public final boolean cancel(boolean mayInterruptIfRunning) { mCancelled.set(true); //在 mFuture 中中断线程 return mFuture.cancel(mayInterruptIfRunning); } 复制代码
整个内部大致的流程就差不过了。将耗时任务封装进 FutureTask,SerialExecutor 对 FutureTask 在进行包装使耗时任务可以串行执行,最后由 THREAD_POOL_EXECUTOR 线程池进行真正的耗时任务调度处理。
FAQ
HandlerThread 与 AsyncTask 的大致实现原理
- HandlerThread:开启一个子线程,创建新的 Looper 并开启 looper 循环,使子线程一直存在,直到 loop 循环退出。 使用时初始化 HandlerThread,并 new 一个 Handler 并传入 Handler 中的 Looper,就可以通过 handler 发送消息,在 handlerMessage 中接受消息并执行相应的耗时任务。
- AsyncTask:通过 executor 将封装了 doInBackground 中的耗时任务的 FutureTask 对象传入到 SerialExecutor 中,SerialExecutor 串行的将任务发送给 THREAD_POOL_EXECUTOR 线程池进行调度。
HandlerThread 与 AsyncTask 完成耗时任务后会怎么样
- HandlerThread 是 loop 循环+Handler 消息处理机制,也就是说,只要 loop 循环不退出,那么线程就不会停止,需要处理耗时任务只需要 Handler 发送对应类型的消息即可。
- AsyncTask 的耗时任务是交给线程池去调度,耗时任务完成后线程的存活与否有线程池的特性决定。而 AsyncTask 的 execute 方法执行一次后,就不可以在此调用。因为内部状态是严格按照 PENDING、RUNNING、FINISHED 顺序变化,不可逆转。在 executeOnExecutor 方法中会检查状态并抛出异常。
HandlerThread 与 AsyncTask 中的耗时事件处理是异步还是同步,可不可以变为另一种处理
HandlerThread
HandlerThread 是在子线程的 loop 循环中进行的耗时操作,只有当前的耗时操作完成,才能获取下一个消息处理,所以是串行的。至于变为并行的,不可以。简单验证下
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss",Locale.US); Handler threadHandler; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); HandlerThread handlerThread = new HandlerThread("workThread"); handlerThread.start(); threadHandler = new Handler(handlerThread.getLooper()){ @Override public void handleMessage(Message msg) { super.handleMessage(msg); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } Log.e("HandlerThread", "message" + msg.what + ":" + df.format(new Date())); } }; } public void onLoginClick(View v){ int i = 0; threadHandler.sendEmptyMessage(++i); threadHandler.sendEmptyMessage(++i); threadHandler.sendEmptyMessage(++i); threadHandler.sendEmptyMessage(++i); threadHandler.sendEmptyMessage(++i); } 复制代码
AsyncTask
自定义一个简单的 AsyncTask
static class MyAsyncTask extends AsyncTask<Void, Void, String>{ String name = "AsyncTask"; private MyAsyncTask(String name){ super(); this.name = name; } @Override protected String doInBackground(Void... voids) { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } return name; } @Override protected void onPostExecute(String string) { super.onPostExecute(string); Log.e("AsyncTask", string + ":" + df.format(new Date())); } } 复制代码
当我们进行一连串的耗时任务时,就会串行处理
new MyAsyncTask("AsyncTask#1").execute(); new MyAsyncTask("AsyncTask#2").execute(); new MyAsyncTask("AsyncTask#3").execute(); new MyAsyncTask("AsyncTask#4").execute(); new MyAsyncTask("AsyncTask#5").execute(); new MyAsyncTask("AsyncTask#6").execute(); new MyAsyncTask("AsyncTask#7").execute(); new MyAsyncTask("AsyncTask#8").execute(); 复制代码
从结果中可以看出严格的按照先入先出串行处理任务。那么能不能变为并发的呢?在源码分析中我们知道串行是受 SerialExecutor 对象进行控制的,而此对象是在 execute 中通过 return executeOnExecutor(sDefaultExecutor, params) 传入进去的。而刚好我们可以调用 executeOnExecutor 方法。所以我们可以自定义线程池甚至传入 AsyncTask 的 THREAD_POOL_EXECUTOR 线程池跳过 SerialExecutor 的串行控制,直接用线程池进行并发处理任务。
new MyAsyncTask("AsyncTask#1").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); new MyAsyncTask("AsyncTask#2").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); new MyAsyncTask("AsyncTask#3").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); new MyAsyncTask("AsyncTask#4").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); new MyAsyncTask("AsyncTask#5").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); new MyAsyncTask("AsyncTask#6").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); new MyAsyncTask("AsyncTask#7").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); new MyAsyncTask("AsyncTask#8").executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); 复制代码
从结果中可以看出,是四个并发进行的任务处理。为什么是 4 个呢?这个是线程池的调度问题。测试用的手机正在工作 CPU 数目是 8,所以核心线程池数量是 Math.max(2, Math.min(CPU_COUNT - 1, 4)) = 4,最大的线程数量是 CPU_COUNT * 2 + 1 = 17。线程池首先会用核心线程处理任务,核心线程满了后,后面的认为就会被放入阻塞队列,当队列也满后,再来任务的话,就会创建非核心线程,取队首任务进行处理,后来的任务放入队尾。如果非核心线程也满了,在来任务就会抛出异常。有个图可能看着会更明白些。
所以说任务少是就只是核心线程在并发处理任务。
那么为什么要默认串行处理任务,那样岂不是丢失了线程池的最大优势?
这是因为默认线程池其实所能容下的线程并不多,就拿 8 核的例子来看,最大线程数为 17,阻塞队列容量为 128,加起来所能容下的最大线程数为 17+128=145,在高并发的情况下很容易就会满,并且 THREAD_POOL_EXECUTOR 对象在整个应用程序中是唯一的。所以默认是串行处理,如果真的有高并发处理的情况,可以根据需求自定义线程池进行并发处理。
HandlerThread 与 AsyncTask 内存泄漏的可能性
- HandlerThread 的内存泄漏在于一定要记得在 Activity 销毁时手动退出线程。因为 loop 循环是一个死循环,如果不手动退出,就会一直存在。
- AsyncTask 的内存泄漏在于一定要用静态内部类的形式,内部类会默认持有 Activity 的引用,而 Activity 与 AsyncTask 的生命周期并不能确定 Activity 更长。
HandlerThread 与 AsyncTask 使用中的注意事项
- 关于 HandlerThread,我没怎么用过,查了网上资料,只有一个说法。给 HandlerThread 设置不同的优先级,cpu 会根据不同的线程优先级对所有线程进行优化。
- 以前的资料都是说 AsyncTask 的对象要在主线程创建,可能后来源码做了修改,现在这个在我看来并不需要,因为内部转换线程的 Handler 的创建并不依赖当前线程的 Looper 对象,而是通过 Looper.getMainLooper() 获取的主线程 Looper。当然,onPreExecute 方法是会受 AsyncTask 对象创建时的线程影响的,因为此方法并不是通过 Handler 的消息传递而执行的。
- 还有一个就是 AsyncTask 取消线程执行的问题,cancel 并不能真正的立刻终止程序的执行,它只是改变了标记状态的变量,当然 cancel 方法中的 mFuture.cancel(mayInterruptIfRunning),如果传入 true 也会在 FutureTask 层进行终止线程的操作,但对于一些不可停止的操作,也只能等待任务完成然后根据标记变量状态调用 onCancelled 还是 onPostExecute。所以说我们应该在 doInBackground 方法中尽可能的不断进行状态的检验,在需要返回时尽早的退出线程。
HandlerThread 与 AsyncTask 的应用场景
- HandlerThread 实际上就是 Thread+Looper 的结合,所以也就适用于单线程+多个耗时任务的场景,比如网络请求、文件读写。而对于耗时长的多个任务,我个人认为因为串行执行的关系,HandlerThread 并不适用。
- AsyncTask 主要用来耗时任务完成后与 UI 线程的交互,不过默认是串行的,可能这也是官方说明 AsyncTask 尽可能执行耗时几秒的操作,不过可以直接通过 executeOnExecutor 变为并行处理任务。
给定大量的耗时任务,耗时操作并不连续,耗时时长长短不一,用哪个
对于这个面试时抛出的问题,我简单说下我的理解。因为我实际开发经验少的可怜,所以说的可能有错误或者很片面,包括上面的几个问题的回答。现在网上的资料真的不敢随便相信,有的还自相矛盾,我忽然明白面试时面试官问我平常都看谁的文章,知道哪些在 Android 方面比较专业的人士的用意了。
大量的耗时操作,如果任务之间没有什么关联的话,在我看来如果是串行处理的话都不怎么好,因为会阻塞后面的任务,而任务之间并不需要有个前后执行的顺序。所以在 AsyncTask 中并行处理比较好。而如果任务之间有关联,则需串行执行,此时就要看这些耗时任务的执行逻辑是否一致,如果不一致的话那就要自定义多个 AsyncTask,也很是麻烦。在我看来 AsyncTask 更多的是强调与 UI 线程的交互吧。
其实对于 Android 中的耗时任务处理,HandlerThread、IntentService、AsyncTask、ThreadPoolExecutor 这几个具体应用场景,有哪些差别,还真的说不出来个所以然来,还需努力。(总之就是菜(滑稽))
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 从面试官的角度谈谈大数据面试
- 面试官:谈谈你对mysql联合索引的认识?
- 从源码的角度谈谈面试常客Handler的内部原理
- 【Java 容器面试题】谈谈你对HashMap 的理解
- 面试官:“谈谈Spring中都用到了那些设计模式?”。
- 面试官 :“谈谈Spring中都用到了哪些设计模式?”
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
CSS 压缩/解压工具
在线压缩/解压 CSS 代码
RGB转16进制工具
RGB HEX 互转工具