内容简介:Volley 是 Google 推出的一款网络通信框架,非常适合数据量小、通信频繁的网络请求,支持并发、缓存和容易扩展、调试等;不过不太适合下载大文件、大量数据的网络请求,因为volley在解析期间将响应放到内存中,我们可以使用okhttp或者系统提供的首先在工程引入volley的library:然后需要我们打开网络权限,我这里直接贴出官网简单请求的示例代码:
Volley 是 Google 推出的一款网络通信框架,非常适合数据量小、通信频繁的网络请求,支持并发、缓存和容易扩展、调试等;不过不太适合下载大文件、大量数据的网络请求,因为volley在解析期间将响应放到内存中,我们可以使用okhttp或者系统提供的 DownloadManager
来下载文件。
一、简单使用
首先在工程引入volley的library:
dependencies { implementation 'com.android.volley:volley:1.1.1' } 复制代码
然后需要我们打开网络权限,我这里直接贴出官网简单请求的示例代码:
final TextView mTextView = (TextView) findViewById(R.id.text); // ... // Instantiate the RequestQueue. RequestQueue queue = Volley.newRequestQueue(this); String url ="http://www.google.com"; // Request a string response from the provided URL. StringRequest stringRequest = new StringRequest(Request.Method.GET, url, new Response.Listener<String>() { @Override public void onResponse(String response) { // Display the first 500 characters of the response string. mTextView.setText("Response is: "+ response.substring(0,500)); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { mTextView.setText("That didn't work!"); } }); // Add the request to the RequestQueue. queue.add(stringRequest); 复制代码
使用相对简单,回调直接在主线程,我们取消某个请求直接这样操作:
-
定义一个标记添加到requests中
public static final String TAG = "MyTag"; StringRequest stringRequest; // Assume this exists. RequestQueue mRequestQueue; // Assume this exists. // Set the tag on the request. stringRequest.setTag(TAG); // Add the request to the RequestQueue. mRequestQueue.add(stringRequest); 复制代码
-
然后我们可以在 onStop() 中取消所有标记的请求
@Override protected void onStop () { super.onStop(); if (mRequestQueue != null) { mRequestQueue.cancelAll(TAG); } } 复制代码
二、源码分析
我们先从Volley这个类入手:
public static RequestQueue newRequestQueue(Context context, BaseHttpStack stack) { BasicNetwork network; if (stack == null) { if (Build.VERSION.SDK_INT >= 9) { network = new BasicNetwork(new HurlStack()); } else { String userAgent = "volley/0"; try { String packageName = context.getPackageName(); PackageInfo info = context.getPackageManager().getPackageInfo(packageName, /* flags= */ 0); userAgent = packageName + "/" + info.versionCode; } catch (NameNotFoundException e) { } network = new BasicNetwork( new HttpClientStack(AndroidHttpClient.newInstance(userAgent))); } } else { network = new BasicNetwork(stack); } return newRequestQueue(context, network); } private static RequestQueue newRequestQueue(Context context, Network network) { File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR); RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheDir), network); queue.start(); return queue; } public static RequestQueue newRequestQueue(Context context) { return newRequestQueue(context, (BaseHttpStack) null); } 复制代码
当我们传递一个 Context
的时候,首先为 BaseHttpStack
为null,会执行到创建 BaseHttpStack
, BaseHttpStack
是一个网络具体的处理请求, Volley
默认提供了基于 HttpURLCollection
的 HurlStack
和基于 HttpClient
的 HttpClientStack
。Android6.0移除了 HttpClient
,Google官方推荐使用 HttpURLCollection
类作为替换。所以这里在API大于9的版本是用的是 HurlStack
,为什么这样选择,详情可见这篇博客 Android访问网络,使用HttpURLConnection还是HttpClient? 。我们使用的是默认的构造, BaseHttpStack
传入为null,如果我们想使用自定义的okhttp替换底层,我们直接继承 HttpStack
重写即可,也可以自定义 Network
和 RequestQueue
, Volley
的高扩展性充分体现。接下来则创建一个 Network
对象,然后实例化 RequestQueue
,首先创建了一个用于缓存的文件夹,然后创建了一个磁盘缓存,将文件缓存到指定目录的硬盘上,默认大小是5M,但是大小可以配置。接下来调用 RequestQueue
的 start()
方法进行启动,我们进入这个方法查看一下:
public void start() { stop(); mCacheDispatcher = new CacheDispatcher(mCacheQueue, mNetworkQueue, mCache, mDelivery); mCacheDispatcher.start(); for (int i = 0; i < mDispatchers.length; i++) { NetworkDispatcher networkDispatcher = new NetworkDispatcher(mNetworkQueue, mNetwork, mCache, mDelivery); mDispatchers[i] = networkDispatcher; networkDispatcher.start(); } } 复制代码
开始启动的时候先停止所有的请求线程和网络缓存线程,然后实例化一个缓存线程并运行,然后一个循环开启 DEFAULT_NETWORK_THREAD_POOL_SIZE
(4)个网络请求线程并运行,一共就是5个线程在后台运行,不断的等待网络请求的到来。
构造了 RequestQueue
之后,我们调用 add()
方法将相应的 Request
传入就开始执行网络请求了,我们看看这个方法:
public <T> Request<T> add(Request<T> request) { //将请求队列和请求关联起来 request.setRequestQueue(this); //添加到正在请求中但是还未完成的集合中 synchronized (mCurrentRequests) { mCurrentRequests.add(request); } //设置请求的一个序列号,通过原子变量的incrementAndGet方法, //以原子方式给当前值加1并获取新值实现请求的优先级 request.setSequence(getSequenceNumber()); //添加一个调试信息 request.addMarker("add-to-queue"); //如果不需要缓存则直接加到网络的请求队列,默认每一个请求都是缓存的, //如果不需要缓存需要调用Request的setShouldCache方法来修改 if (!request.shouldCache()) { mNetworkQueue.add(request); return request; } //加到缓存的请求队列 mCacheQueue.add(request); return request; } 复制代码
关键地方都写了注释,主要作用就是将请求加到请求队列,执行网络请求或者从缓存中获取结果。网络和缓存的请求都是一个优先级阻塞队列,按照优先级出队。上面几个关键步骤,添加到请求集合里面还有设置优先级以及添加到缓存和请求队列都是线程安全的,要么加锁,要么使用线程安全的队列或者原子操作。
接下来我们看看添加到 CacheDispatcher
缓存请求队列的 run
方法:
@Override public void run() { if (DEBUG) VolleyLog.v("start new dispatcher"); Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); //初始化DiskBasedCache的缓存类 mCache.initialize(); while (true) { try { processRequest(); } catch (InterruptedException e) { if (mQuit) { Thread.currentThread().interrupt(); return; } VolleyLog.e( "Ignoring spurious interrupt of CacheDispatcher thread; " + "use quit() to terminate it"); } } } 复制代码
接下来的重点是看看 processRequest()
这个方法:
private void processRequest() throws InterruptedException { //从缓存队列取出请求 final Request<?> request = mCacheQueue.take(); processRequest(request); } @VisibleForTesting void processRequest(final Request<?> request) throws InterruptedException { request.addMarker("cache-queue-take"); // 如果请求被取消,我们可以通过RequestQueue的回调接口来监听 if (request.isCanceled()) { request.finish("cache-discard-canceled"); return; } // 从缓存中获取Cache.Entry Cache.Entry entry = mCache.get(request.getCacheKey()); //没有取到缓存 if (entry == null) { request.addMarker("cache-miss"); // 缓存未命中,对于可缓存的请求先去检查是否有相同的请求是否已经在运行中, //如果有的话先加入请求等待队列,等待请求完成,返回true;如果返回false则表示第一次请求 if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) { //加入到网络请求的阻塞队列 mNetworkQueue.put(request); } return; } // 如果缓存完全过期,处理过程跟上面类似 if (entry.isExpired()) { request.addMarker("cache-hit-expired"); //设置请求缓存的entry到这个request中 request.setCacheEntry(entry); if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) { mNetworkQueue.put(request); } return; } //缓存命中,将数据解析并返回到request的抽象方法中 request.addMarker("cache-hit"); Response<?> response = request.parseNetworkResponse( new NetworkResponse(entry.data, entry.responseHeaders)); request.addMarker("cache-hit-parsed"); //判断请求结果是否需要刷新 if (!entry.refreshNeeded()) { // 未过期的缓存命中,通过ExecutorDelivery回调给我们的request子类的接口中, // 我们在使用的时候就可以通过StringRequest、JsonRequest等拿到结果, // 切换到主线程也是在这个类里执行的 mDelivery.postResponse(request, response); } else { request.addMarker("cache-hit-refresh-needed"); request.setCacheEntry(entry); // 将这个响应标记为中间值,即这个响应是软过期的,那么第二个响应正在请求随时到来 response.intermediate = true; if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) { //发起网络请求,这里为什么直接调用上面的mNetworkQueue.put(request);呢, //主要是为了添加一个已经分发的标记,在响应分发的时候不再回调给用户, //不然就回调了两次 mDelivery.postResponse( request, response, new Runnable() { @Override public void run() { try { mNetworkQueue.put(request); } catch (InterruptedException e) { // Restore the interrupted status Thread.currentThread().interrupt(); } } }); } else { //这里第三个参数传递null,不用再去分发,因为已经有相同的请求已经在执行, //直接添加到了等待请求的列表中,然后返回的时候从已经执行的请求收到响应 mDelivery.postResponse(request, response); } } } 复制代码
这部分主要是对请求的缓存判断,是否过期以及需要刷新缓存。我们调用取消所有请求或者取消某个请求实质上就是对 mCanceled
这个变量赋值,然后在缓存线程或者网络线程里面都回去判断这个值,就完成了取消。上面的 isExpired
和 refreshNeeded
,两个区别就是,前者如果过期就直接请求最新的内容,后者就是还在软过期的时间内,但是把内容返回给用户还是会发起请求,两者一个与ttl值相比,另一个与softTtl相比。
其中有一个WaitingRequestManager,如果有相同的请求那么就需要一个暂存的地方,这个类就是做的这个操作
private static class WaitingRequestManager implements Request.NetworkRequestCompleteListener { //所有等待请求的集合,键是缓存的key private final Map<String, List<Request<?>>> mWaitingRequests = new HashMap<>(); private final CacheDispatcher mCacheDispatcher; WaitingRequestManager(CacheDispatcher cacheDispatcher) { mCacheDispatcher = cacheDispatcher; } //请求接受到一个有效的响应,后面等待的相同请求就可以使用这个响应 @Override public void onResponseReceived(Request<?> request, Response<?> response) { //如果缓存为空或者已经过期,那么就释放等待的请求 if (response.cacheEntry == null || response.cacheEntry.isExpired()) { onNoUsableResponseReceived(request); return; } String cacheKey = request.getCacheKey(); //等待的请求的集合 List<Request<?>> waitingRequests; synchronized (this) { //从map里面移除这个请求的集合 waitingRequests = mWaitingRequests.remove(cacheKey); } if (waitingRequests != null) { if (VolleyLog.DEBUG) { VolleyLog.v( "Releasing %d waiting requests for cacheKey=%s.", waitingRequests.size(), cacheKey); } // 里面所有的请求都分发到相应的回调执行,下面会讲解 for (Request<?> waiting : waitingRequests) { mCacheDispatcher.mDelivery.postResponse(waiting, response); } } } //没有收到相应,则需要释放请求 @Override public synchronized void onNoUsableResponseReceived(Request<?> request) { String cacheKey = request.getCacheKey(); List<Request<?>> waitingRequests = mWaitingRequests.remove(cacheKey); if (waitingRequests != null && !waitingRequests.isEmpty()) { if (VolleyLog.DEBUG) { VolleyLog.v( "%d waiting requests for cacheKey=%s; resend to network", waitingRequests.size(), cacheKey); } //下面这个请求执会重新执行,将这个移除添加到 Request<?> nextInLine = waitingRequests.remove(0); //将剩下的请求放到等待请求的map中 mWaitingRequests.put(cacheKey, waitingRequests); //在request里面注册一个回调接口,因为重新开始请求,需要重新注册一个监听, //后面请求成功失败以及取消都可以收到回调 nextInLine.setNetworkRequestCompleteListener(this); try { //从上面if判断方法可以得出:waitingRequests != null && !waitingRequests.isEmpty() //排除了第一次请求失败、取消的情况,后面的那个条件则表示这个等待请求队列必须要有一个请求, //同时满足才会执行这里面的代码,一般只要这里面的请求执行成功一次后续所有的请求都会被移除, //所以这里对多个请求的情况,失败一次,那么后续的请求会继续执行 mCacheDispatcher.mNetworkQueue.put(nextInLine); } catch (InterruptedException iex) { VolleyLog.e("Couldn't add request to queue. %s", iex.toString()); // Restore the interrupted status of the calling thread (i.e. NetworkDispatcher) Thread.currentThread().interrupt(); // Quit the current CacheDispatcher thread. mCacheDispatcher.quit(); } } } //对于可以缓存的请求,相同缓存的请求已经在运行中就添加到一个发送队列, //等待运行中的队列请求完成,返回true表示已经有请求在运行,false则是第一次执行 private synchronized boolean maybeAddToWaitingRequests(Request<?> request) { String cacheKey = request.getCacheKey(); // 存在相同的请求则把请求加入到相同缓存键的集合中 if (mWaitingRequests.containsKey(cacheKey)) { // There is already a request in flight. Queue up. List<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey); //如果包含相同的请求但是有可能是第二次请求,前面第一次请求插入null了 if (stagedRequests == null) { stagedRequests = new ArrayList<>(); } request.addMarker("waiting-for-response"); stagedRequests.add(request); mWaitingRequests.put(cacheKey, stagedRequests); if (VolleyLog.DEBUG) { VolleyLog.d("Request for cacheKey=%s is in flight, putting on hold.", cacheKey); } return true; } else { //第一次请求那么则插入一个null,表示当前有一个请求正在运行 mWaitingRequests.put(cacheKey, null); //注册一个接口监听 request.setNetworkRequestCompleteListener(this); if (VolleyLog.DEBUG) { VolleyLog.d("new request, sending to network %s", cacheKey); } return false; } } } 复制代码
这个类主要是避免相同的请求多次请求,而且在 NetworkDispatcher
里面也会通过这个接口回调相应的值在这里执行,最终比如在网络请求返回304、请求取消或者异常那么都会在这里来处理,如果收到响应则会把值回调给用户,后面的请求也不会再去请求,如果无效的响应则会做一些释放等待的请求操作,请求完成也会将后面相同的请求回调给用户,三个方法都在不同的地方发挥作用。
我们接下来看看 NetworkDispatcher
网络请求队列的 run
方法中的 processRequest
方法:
@VisibleForTesting void processRequest(Request<?> request) { long startTimeMs = SystemClock.elapsedRealtime(); try { request.addMarker("network-queue-take"); // 请求被取消了,就不执行网络请求, if (request.isCanceled()) { request.finish("network-discard-cancelled"); request.notifyListenerResponseNotUsable(); return; } addTrafficStatsTag(request); // 这里就是执行网络请求的地方 NetworkResponse networkResponse = mNetwork.performRequest(request); request.addMarker("network-http-complete"); // 如果服务器返回304响应,即没有修改过, //缓存依然是有效的并且是在需要刷新的有效期内,那么则不需要解析响应 if (networkResponse.notModified && request.hasHadResponseDelivered()) { request.finish("not-modified"); //没有收到来自网络的有效响应,释放请求 request.notifyListenerResponseNotUsable(); return; } // 在工作线程中解析这些响应 Response<?> response = request.parseNetworkResponse(networkResponse); request.addMarker("network-parse-complete"); // 将缓存写入到应用 if (request.shouldCache() && response.cacheEntry != null) { mCache.put(request.getCacheKey(), response.cacheEntry); request.addMarker("network-cache-written"); } // 标记此请求已将分发 request.markDelivered(); //将请求的响应回调给用户 mDelivery.postResponse(request, response); //请求接受到了一个响应,其他相同的请求可以使用这个响应 request.notifyListenerResponseReceived(response); } catch (VolleyError volleyError) { ... } } 复制代码
这里才是网络请求的真正执行以及解析分发的地方,重点看两个地方的代码,执行和解析,我们先看看执行网络请求这个代码,执行的地方是 BasicNetwork.performRequest
,下面看看这个方法:
@Override public NetworkResponse performRequest(Request<?> request) throws VolleyError { long requestStart = SystemClock.elapsedRealtime(); while (true) { HttpResponse httpResponse = null; byte[] responseContents = null; List<Header> responseHeaders = Collections.emptyList(); try { // 构造缓存的头部,添加If-None-Match和If-Modified-Since,都是http/1.1中控制协商缓存的两个字段, // If-None-Match:客服端再次发起请求时,携带上次请求返回的唯一标识Etag值, //服务端用携带的值和最后修改的值作对比,最后修改时间大于携带的字段值则返回200,否则304; // If-Modified-Since:客服端再次发起请求时,携带上次请求返回的Last-Modified值, //服务端用携带的值和服务器的Etag值作对比,一致则返回304 Map<String, String> additionalRequestHeaders = getCacheHeaders(request.getCacheEntry()); //因为现在一般的sdk都是大于9的,那么这里执行的就是HurlStack的executeRequest方法, //执行网络请求,和我们平时使用HttpURLConnection请求网络大致相同 httpResponse = mBaseHttpStack.executeRequest(request, additionalRequestHeaders); int statusCode = httpResponse.getStatusCode(); responseHeaders = httpResponse.getHeaders(); // 服务端返回304时,那么就表示资源无更新,可以继续使用缓存的值 if (statusCode == HttpURLConnection.HTTP_NOT_MODIFIED) { Entry entry = request.getCacheEntry(); if (entry == null) { return new NetworkResponse( HttpURLConnection.HTTP_NOT_MODIFIED, /* data= */ null, /* notModified= */ true, SystemClock.elapsedRealtime() - requestStart, responseHeaders); } // 将缓存头和响应头组合在一起,一次响应就完成了 List<Header> combinedHeaders = combineHeaders(responseHeaders, entry); return new NetworkResponse( HttpURLConnection.HTTP_NOT_MODIFIED, entry.data, /* notModified= */ true, SystemClock.elapsedRealtime() - requestStart, combinedHeaders); } // 如果返回204,执行成功,没有数据,这里需要检查 InputStream inputStream = httpResponse.getContent(); if (inputStream != null) { responseContents = inputStreamToBytes(inputStream, httpResponse.getContentLength()); } else { //返回204,就返回一个空的byte数组 responseContents = new byte[0]; } // if the request is slow, log it. long requestLifetime = SystemClock.elapsedRealtime() - requestStart; logSlowRequests(requestLifetime, request, responseContents, statusCode); if (statusCode < 200 || statusCode > 299) { throw new IOException(); } return new NetworkResponse( statusCode, responseContents, /* notModified= */ false, SystemClock.elapsedRealtime() - requestStart, responseHeaders); } catch (SocketTimeoutException e) { //异常进行重新请求等... } } } 复制代码
这里主要执行了添加缓存头并发起网络请求,然后将返回值组装成一个 NetworkResponse
值返回,接下来我们看看是如何解析这个值的,解析是由 Request
的子类去实现的,我们就看系统提供的 StringRequest
:
@Override @SuppressWarnings("DefaultCharset") protected Response<String> parseNetworkResponse(NetworkResponse response) { String parsed; try { parsed = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); } catch (UnsupportedEncodingException e) { // Since minSdkVersion = 8, we can't call // new String(response.data, Charset.defaultCharset()) // So suppress the warning instead. parsed = new String(response.data); } return Response.success(parsed, HttpHeaderParser.parseCacheHeaders(response)); } 复制代码
我们可以看到将值组装成一个String,然后组装成一个 Response
返回,接下来看看这里如何将这个值回调给用户的这个方法 mDelivery.postResponse(request, response)
,这里我们先重点看看这个类 ExecutorDelivery
:
public class ExecutorDelivery implements ResponseDelivery { //构造执行已提交的Runnable任务对象 private final Executor mResponsePoster; //这里在RequestQueue构造参数中初始化,new ExecutorDelivery(new Handler(Looper.getMainLooper())), //那么这里runnable就通过绑定主线程的Looper的Handler对象投递到主线程中执行 public ExecutorDelivery(final Handler handler) { // Make an Executor that just wraps the handler. mResponsePoster = new Executor() { @Override public void execute(Runnable command) { handler.post(command); } }; } public ExecutorDelivery(Executor executor) { mResponsePoster = executor; } //这个方法就是我们NetworkDispatcher里面调用的方法,调用下面这个三个参数的构造方法 @Override public void postResponse(Request<?> request, Response<?> response) { postResponse(request, response, null); } @Override public void postResponse(Request<?> request, Response<?> response, Runnable runnable) { request.markDelivered(); request.addMarker("post-response"); //构造了一个ResponseDeliveryRunnable类,传入execute,现在这个runnable就是在主线程里执行 mResponsePoster.execute(new ResponseDeliveryRunnable(request, response, runnable)); } @Override public void postError(Request<?> request, VolleyError error) { request.addMarker("post-error"); Response<?> response = Response.error(error); mResponsePoster.execute(new ResponseDeliveryRunnable(request, response, null)); } /** A Runnable used for delivering network responses to a listener on the main thread. */ @SuppressWarnings("rawtypes") private static class ResponseDeliveryRunnable implements Runnable { private final Request mRequest; private final Response mResponse; private final Runnable mRunnable; public ResponseDeliveryRunnable(Request request, Response response, Runnable runnable) { mRequest = request; mResponse = response; mRunnable = runnable; } @SuppressWarnings("unchecked") @Override public void run() { //请求取消,那么就不分发给用户 if (mRequest.isCanceled()) { mRequest.finish("canceled-at-delivery"); return; } // 根据isSuccess这个值来提供相应的回调给用户,调用Response会通过error的值是否为null来确定这个值, //我们调用VolleyError这个构造函数的时候就为这个值就为false if (mResponse.isSuccess()) { mRequest.deliverResponse(mResponse.result); } else { mRequest.deliverError(mResponse.error); } // 如果这是一个在软过期时间的请求的响应,就添加一个标记,否则就结束 if (mResponse.intermediate) { mRequest.addMarker("intermediate-response"); } else { mRequest.finish("done"); } // 在CacheDispatcher里面软过期那个地方直接调用三个参数的构造方法,通过这个runnable就执行run方法 if (mRunnable != null) { mRunnable.run(); } } } } 复制代码
上面方法主要是将值回调给用户,那么整个网络请求大致就完成了,其中还涉及很多细节的东西,但是大致流程是走通了,不得不说这个库有很多值得我们学习的地方。
三、总结
现在我们看官网的一张图,总结一下整个流程:
- 蓝色是主线程
- 绿色是缓存线程
- 黄色是网络线程
我们可以看到首先是请求添加到 RequestQueue
里,首先是添加到缓存队列,然后查看是否已经缓存,如果有并且在有效期内的缓存直接回调给用户,如果没有查找到,那么则需要添加到网络请求队列重新请求并且解析响应、写入缓存在发送到主线程给用户回调。
参考以及相关链接
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- flask 源码解析5:请求
- Retrofit网络请求源码解析
- Okhttp同步请求源码分析
- Nginx源码阅读笔记-处理HTTP请求
- Nginx源码阅读笔记-接收HTTP请求流程
- 搭上 Spring Boot 请求处理源码分析专车
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
网站转换率优化之道
[美] Khalid Saleh、[美] Ayat Shukairy / 顾 毅 / 人民邮电出版社 / 2012-4 / 45.00元
内容简介: 怎样才能将访问者转化为顾客? 本书提供了一些切实可行的建议,比如如何说服访问者作出购买决定,如何避免用户因信息过量或导航繁琐而离开网站等。不论你是在设计或营销大型电子商务网站,还是在管理中小型在线业务,都可以从本书学会怎样使用市场营销原则、设计方法、可用性原则和分析数据来持续提升网站的转换率。 作者帮助过众多公司吸引在线顾客,有着丰富的实战经验,在书中细致讨论了从访问......一起来看看 《网站转换率优化之道》 这本书的介绍吧!