android-----Volley框架源码分析

        作为Google的亲儿子,Volley框架从诞生之日起就受到极大推崇,他简单且适用于异步环境下的频繁网络操作,但是对于上传文件或者想要post一些较大数据的场合,显然他是束手无策的,这篇博文我会从源码角度带大家看看Volley框架到底是怎么个执行流程;

        平常我们使用Volley的标准步骤是:

        (1)创建一个RequestQueue队列;

        (2)创建一个Request对象(当然实际中可能就是Request的子类了,比如:StringRequest、JsonRequest等等);

        (3)调用RequestQueue的add方法将Request对象添加到请求队列中;

        那么很自然,源码分析应该是从创建RequestQueue队列开始的,创建RequestQueue的语句是Volley.newRequestQueue(context)

 public static RequestQueue newRequestQueue(Context context) {
        return newRequestQueue(context, null);
    }

	public static RequestQueue newRequestQueue(Context context, HttpStack stack)
    {
    	return newRequestQueue(context, stack, -1);
    }

	public static RequestQueue newRequestQueue(Context context, HttpStack stack, int maxDiskCacheBytes) {
        File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR);

        String userAgent = "volley/0";
        try {
            String packageName = context.getPackageName();
            PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0);
            userAgent = packageName + "/" + info.versionCode;
        } catch (NameNotFoundException e) {
        }

        if (stack == null) {
            if (Build.VERSION.SDK_INT >= 9) {
                stack = new HurlStack();
            } else {
                // Prior to Gingerbread, HttpUrlConnection was unreliable.
                // See: http://android-developers.blogspot.com/2011/09/androids-http-clients.html
                stack = new HttpClientStack(AndroidHttpClient.newInstance(userAgent));
            }
        }

        Network network = new BasicNetwork(stack);
        
        RequestQueue queue;
        if (maxDiskCacheBytes <= -1)
        {
        	// No maximum size specified
        	queue = new RequestQueue(new DiskBasedCache(cacheDir), network);
        }
        else
        {
        	// Disk cache size specified
        	queue = new RequestQueue(new DiskBasedCache(cacheDir, maxDiskCacheBytes), network);
        }

        queue.start();

        return queue;
    }

        newRequestQueue有四个构造函数,但是他们最后都会执行到newRequestQueue(Context context, HttpStack stack, int maxDiskCacheBytes)这个构造函数上面,我只列出了用到的3个,newRequestQueue(context)首先会调用newRequestQueue(context,null),接着调用newRequestQueue(context,null,-1),程序走到第21行时,判断stack是否等于null,等于的话则进入if语句块,如果SDK版本号大于9的话,则创建HurlStack对象,否则的话创建HttpClientStack对象,接着在第31行创建一个参数为stack的Network对象,在第34行判断maxDiskCacheBytes是否小于等于-1,这里我们传入的参数是-1,则进入if语句块,利用刚刚生成的network对象创建RequestQueue对象,我们来看看RequestQueue的构造函数

public RequestQueue(Cache cache, Network network) {
        this(cache, network, DEFAULT_NETWORK_THREAD_POOL_SIZE);
    }

    public RequestQueue(Cache cache, Network network, int threadPoolSize) {
        this(cache, network, threadPoolSize,
                new ExecutorDelivery(new Handler(Looper.getMainLooper())));
    }

    public RequestQueue(Cache cache, Network network, int threadPoolSize,
            ResponseDelivery delivery) {
        mCache = cache;
        mNetwork = network;
        mDispatchers = new NetworkDispatcher[threadPoolSize];
        mDelivery = delivery;
    }
        可以看到调用RequestQueue(Cache cache, Network network)实际上最后都会调用到有四个参数的构造函数上,其中DEFAULT_NETWORK_THREAD_POOL_SIZE的值为4,表示网络请求缓存池的大小是4;

    private static final int DEFAULT_NETWORK_THREAD_POOL_SIZE = 4;

        回到newRequestQueue函数,在创建出RequestQueue队列之后,第45行启动了我们的请求队列,很自然我们需要到RequestQueue里面的start方法看看到底做了哪些启动事件;

 public void start() {
        stop();  // Make sure any currently running dispatchers are stopped.
        // Create the cache dispatcher and start it.
        mCacheDispatcher = new CacheDispatcher(mCacheQueue, mNetworkQueue, mCache, mDelivery);
        mCacheDispatcher.start();

        // Create network dispatchers (and corresponding threads) up to the pool size.
        for (int i = 0; i < mDispatchers.length; i++) {
            NetworkDispatcher networkDispatcher = new NetworkDispatcher(mNetworkQueue, mNetwork,
                    mCache, mDelivery);
            mDispatchers[i] = networkDispatcher;
            networkDispatcher.start();
        }
    }
        首先执行stop方法暂停当前的缓存线程和网络请求线程:

 public void stop() {
        if (mCacheDispatcher != null) {
            mCacheDispatcher.quit();
        }
        for (int i = 0; i < mDispatchers.length; i++) {
            if (mDispatchers[i] != null) {
                mDispatchers[i].quit();
            }
        }
    }
        默认情况下的缓存线程只有一个,网络请求线程有4个;

        接着start方法的第4行重新创建一个缓存线程,并且启动该线程,第8行通过for循环默认创建4个网络请求线程,并且启动每个线程,注意CacheDispatcher和NetworkDispatcher是继承自Thread的,所以本质上他们还是线程:

public class CacheDispatcher extends Thread {
public class NetworkDispatcher extends Thread {
        这也就解释了在你使用Volley创建RequestQueue队列之后,同时会看到还有另外5个线程的原因了;

        接着我们便去创建自己的Request对象,随后调用add方法将当前的Request对象添加到了RequestQueue队列中,那么,add方法到底做了些什么事呢?

public <T> Request<T> add(Request<T> request) {
        // Tag the request as belonging to this queue and add it to the set of current requests.
        request.setRequestQueue(this);
        synchronized (mCurrentRequests) {
            mCurrentRequests.add(request);
        }

        // Process requests in the order they are added.
        request.setSequence(getSequenceNumber());
        request.addMarker("add-to-queue");

        // If the request is uncacheable, skip the cache queue and go straight to the network.
        if (!request.shouldCache()) {
            mNetworkQueue.add(request);
            return request;
        }

        // Insert request into stage if there's already a request with the same cache key in flight.
        synchronized (mWaitingRequests) {
            String cacheKey = request.getCacheKey();
            if (mWaitingRequests.containsKey(cacheKey)) {
                // There is already a request in flight. Queue up.
                Queue<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey);
                if (stagedRequests == null) {
                    stagedRequests = new LinkedList<Request<?>>();
                }
                stagedRequests.add(request);
                mWaitingRequests.put(cacheKey, stagedRequests);
                if (VolleyLog.DEBUG) {
                    VolleyLog.v("Request for cacheKey=%s is in flight, putting on hold.", cacheKey);
                }
            } else {
                // Insert 'null' queue for this cacheKey, indicating there is now a request in
                // flight.
                mWaitingRequests.put(cacheKey, null);
                mCacheQueue.add(request);
            }
            return request;
        }
    }

        第3--10行设置一些Request请求的参数,接着第13行判断当前请求是否可以被缓存,如果不可以的话会将当前请求加入到网络请求队列中同时直接返回request结束add方法,可以缓存的话(默认情况下是可以缓存的,因为在Request类中存在这么一句代码:private boolean mShouldCache = true;),则会跳过if语句去执行synchronized语句块,首先会到第21行查看在mWaitingRequests中是否存在cacheKey对应的对象,mWaitingRequests是一个Map对象,而cacheKey的值是通过getCacheKey得到的,实际上就是一个url,具体值到底是什么可以在Request.java类中看到如下源码:

   public String getCacheKey() {
        return getUrl();
    }
<pre name="code" class="java">   /** The redirect url to use for 3xx http responses */
    private String mRedirectUrl;
   /** URL of this request. */
    private final String mUrl;
   public String getUrl() { return (mRedirectUrl != null) ? mRedirectUrl : mUrl; }
 

getCacheKey会调用getUrl,而getUrl返回的值就是重定向或者我们本身所请求的url了,接着分析add方法,如果mWaitingRequests中存在cacheKey的话,则首先会在23行获得该cacheKey所对应的value(这个值是一个Queue队列)值,接着在第27行将当前的Request请求添加到刚刚获得的队列中,随后28行更新mWaitingRequests中对应于cacheKey的value值;如果mWaitingRequests中不存在cacheKey的话,则在36行将当前Request请求添加到缓存队列中;

        我们发现上面的add操作不是把当前请求添加到网络请求队列中就是将其添加到缓存队列中,两个队列中的请求真正的执行者就是我们之前创建的那5个线程了,因为他们都是线程,所以执行的代码必定出现在各自的run方法中,首先来看看缓存线程的run方法,它位于CacheDispatcher.java类中:

   @Override
    public void run() {
        if (DEBUG) VolleyLog.v("start new dispatcher");
        Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

        // Make a blocking call to initialize the cache.
        mCache.initialize();

        while (true) {
            try {
                // Get a request from the cache triage queue, blocking until
                // at least one is available.
                final Request<?> request = mCacheQueue.take();
                request.addMarker("cache-queue-take");

                // If the request has been canceled, don't bother dispatching it.
                if (request.isCanceled()) {
                    request.finish("cache-discard-canceled");
                    continue;
                }

                // Attempt to retrieve this item from cache.
                Cache.Entry entry = mCache.get(request.getCacheKey());
                if (entry == null) {
                    request.addMarker("cache-miss");
                    // Cache miss; send off to the network dispatcher.
                    mNetworkQueue.put(request);
                    continue;
                }

                // If it is completely expired, just send it to the network.
                if (entry.isExpired()) {
                    request.addMarker("cache-hit-expired");
                    request.setCacheEntry(entry);
                    mNetworkQueue.put(request);
                    continue;
                }

                // We have a cache hit; parse its data for delivery back to the request.
                request.addMarker("cache-hit");
                Response<?> response = request.parseNetworkResponse(
                        new NetworkResponse(entry.data, entry.responseHeaders));
                request.addMarker("cache-hit-parsed");

                if (!entry.refreshNeeded()) {
                    // Completely unexpired cache hit. Just deliver the response.
                    mDelivery.postResponse(request, response);
                } else {
                    // Soft-expired cache hit. We can deliver the cached response,
                    // but we need to also send the request to the network for
                    // refreshing.
                    request.addMarker("cache-hit-refresh-needed");
                    request.setCacheEntry(entry);

                    // Mark the response as intermediate.
                    response.intermediate = true;

                    // Post the intermediate response back to the user and have
                    // the delivery then forward the request along to the network.
                    mDelivery.postResponse(request, response, new Runnable() {
                        @Override
                        public void run() {
                            try {
                                mNetworkQueue.put(request);
                            } catch (InterruptedException e) {
                                // Not much we can do about this.
                            }
                        }
                    });
                }

            } catch (InterruptedException e) {
                // We may have been interrupted because it was time to quit.
                if (mQuit) {
                    return;
                }
                continue;
            }
        }
    }
        可以看到此run方法第9行是while(true)死循环,说明这个线程会一直在后台执行的,第13行首先会从缓存队列中取出请求,接着在第23行获取当前Request对应的缓存中的内容,第24行进行判断,如果不存在的话进入if语句块,将当前请求添加到网络请求队列中,并且跳出此次循环;如果当前缓存中存在与当前Request对应的缓存内容的话,接着在32行判断该缓存内容是否已经过期,如果已经过期的话,则执行if语句块,将当前请求添加到网络请求队列中,同时也跳出本次循环的剩余内容;如果当前Request对应的缓存内容即存在也没过期的话,就会执行后面的内容,在第41行会调用Request的parseNetworkResponse方法 :

 abstract protected Response<T> parseNetworkResponse(NetworkResponse response);
可以看到,这只是一个抽象方法,具体需要子类去实现,比如StringRequest的parseNetworkResponse方法为:

 @Override
    protected Response<String> parseNetworkResponse(NetworkResponse response) {
        String parsed;
        try {
            parsed = new String(response.data, HttpHeaderParser.parseCharset(response.headers));
        } catch (UnsupportedEncodingException e) {
            parsed = new String(response.data);
        }
        return Response.success(parsed, HttpHeaderParser.parseCacheHeaders(response));
    }

        回到CacheDispatcher的run方法第45行会查看当前的cache缓存内容如果是没有过期的话,直接调用mDelivery的postResponse将response结果发送出去,mDelivery是ResponseDelivery类型的,他是一个接口,我们找到他的一个实现类ExecutorDelivery,它里面的postResponse方法如下:

@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");
        mResponsePoster.execute(new ResponseDeliveryRunnable(request, response, runnable));
    }
         其中mResponsePoster的execute方法传入了一个ResponseDeliveryRunnable类型的对象,他是ExecutorDelivery的私有内部类,继承自Runnable:

    private class ResponseDeliveryRunnable implements Runnable {
         调用mResponsePoster的execute方法其实就是调用ResponseDeliveryRunnable的run方法,来看看它里面做了些什么事:

public void run() {
            // If this request has canceled, finish it and don't deliver.
            if (mRequest.isCanceled()) {
                mRequest.finish("canceled-at-delivery");
                return;
            }

            // Deliver a normal response or error, depending.
            if (mResponse.isSuccess()) {
                mRequest.deliverResponse(mResponse.result);
            } else {
                mRequest.deliverError(mResponse.error);
            }

            // If this is an intermediate response, add a marker, otherwise we're done
            // and the request can be finished.
            if (mResponse.intermediate) {
                mRequest.addMarker("intermediate-response");
            } else {
                mRequest.finish("done");
            }

            // If we have been provided a post-delivery runnable, run it.
            if (mRunnable != null) {
                mRunnable.run();
            }
       }
         第9行会判断请求是否成功,成功的话会调用Request的deliverResponse方法,失败的话调用Request的deliverError方法:

 abstract protected void deliverResponse(T response);
        可以看到Request的deliverResponse是个抽象方法,需要我们在子类中实现,比如:StringRequest中的实现是:

 @Override
    protected void deliverResponse(String response) {
        mListener.onResponse(response);
    }
         他会回调Listener的onResponse方法,而这个方法就是我们在平常返回结果执行成功后需要自己实现的方法了;

        而Request的deliverError就是一个具体方法了:

public void deliverError(VolleyError error) {
        if (mErrorListener != null) {
            mErrorListener.onErrorResponse(error);
        }
    }
        他会调用ErrorListener的onErrorResponse方法,这也就是我们平常返回结果执行失败后需要回调的方法,onErrorResponse里面的代码需要我们自己实现;

        这样的话,缓存队列中的线程执行过程已经分析完毕了,接下来就是网络请求队列中的线程执行流程了,其实想想也知道,两者肯定有共同的地方,只不过缓存线程的话,因为已经存在返回的结果了,所以我们只需要将该结果进行适当的解析并且返回给主线程就可以了,网络请求线程的话就需要我们首先先去网络中获取到结果随后才能进行适当的解析并且返回给主线程,好的,接下来从源码角度看看Volley是怎么实现的:

        查看NetworkDispatcher的run方法源码如下:

 @Override
    public void run() {
        Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
        while (true) {
            long startTimeMs = SystemClock.elapsedRealtime();
            Request<?> request;
            try {
                // Take a request from the queue.
                request = mQueue.take();
            } catch (InterruptedException e) {
                // We may have been interrupted because it was time to quit.
                if (mQuit) {
                    return;
                }
                continue;
            }

            try {
                request.addMarker("network-queue-take");

                // If the request was cancelled already, do not perform the
                // network request.
                if (request.isCanceled()) {
                    request.finish("network-discard-cancelled");
                    continue;
                }

                addTrafficStatsTag(request);

                // Perform the network request.
                NetworkResponse networkResponse = mNetwork.performRequest(request);
                request.addMarker("network-http-complete");

                // If the server returned 304 AND we delivered a response already,
                // we're done -- don't deliver a second identical response.
                if (networkResponse.notModified && request.hasHadResponseDelivered()) {
                    request.finish("not-modified");
                    continue;
                }

                // Parse the response here on the worker thread.
                Response<?> response = request.parseNetworkResponse(networkResponse);
                request.addMarker("network-parse-complete");

                // Write to cache if applicable.
                // TODO: Only update cache metadata instead of entire record for 304s.
                if (request.shouldCache() && response.cacheEntry != null) {
                    mCache.put(request.getCacheKey(), response.cacheEntry);
                    request.addMarker("network-cache-written");
                }

                // Post the response back.
                request.markDelivered();
                mDelivery.postResponse(request, response);
            } catch (VolleyError volleyError) {
                volleyError.setNetworkTimeMs(SystemClock.elapsedRealtime() - startTimeMs);
                parseAndDeliverNetworkError(request, volleyError);
            } catch (Exception e) {
                VolleyLog.e(e, "Unhandled exception %s", e.toString());
                VolleyError volleyError = new VolleyError(e);
                volleyError.setNetworkTimeMs(SystemClock.elapsedRealtime() - startTimeMs);
                mDelivery.postError(request, volleyError);
            }
        }
    }

        首先看到第4行和CacheDispatcher一样,同样也是一个while(true)的死循环,表明网络请求线程也是会在后台一直查看网络请求队列中是否有需要执行的请求的,第9行从网络请求队列中取出队头元素,接着第23行判断请求是否被暂停,被暂停的话则结束本次循环,没有的话执行到31行,通过Network的performRequest方法执行request请求,而Network只是一个接口,我们找到他的一个实现类BasicNetwork来看看它里面performRequest这个方法的源码:

@Override
    public NetworkResponse performRequest(Request<?> request) throws VolleyError {
        long requestStart = SystemClock.elapsedRealtime();
        while (true) {
            HttpResponse httpResponse = null;
            byte[] responseContents = null;
            Map<String, String> responseHeaders = Collections.emptyMap();
            try {
                // Gather headers.
                Map<String, String> headers = new HashMap<String, String>();
                addCacheHeaders(headers, request.getCacheEntry());
                httpResponse = mHttpStack.performRequest(request, headers);
                StatusLine statusLine = httpResponse.getStatusLine();
                int statusCode = statusLine.getStatusCode();

                responseHeaders = convertHeaders(httpResponse.getAllHeaders());
                // Handle cache validation.
                if (statusCode == HttpStatus.SC_NOT_MODIFIED) {

                    Entry entry = request.getCacheEntry();
                    if (entry == null) {
                        return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED, null,
                                responseHeaders, true,
                                SystemClock.elapsedRealtime() - requestStart);
                    }

                    // A HTTP 304 response does not have all header fields. We
                    // have to use the header fields from the cache entry plus
                    // the new ones from the response.
                    // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
                    entry.responseHeaders.putAll(responseHeaders);
                    return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED, entry.data,
                            entry.responseHeaders, true,
                            SystemClock.elapsedRealtime() - requestStart);
                }
                
                // Handle moved resources
                if (statusCode == HttpStatus.SC_MOVED_PERMANENTLY || statusCode == HttpStatus.SC_MOVED_TEMPORARILY) {
                	String newUrl = responseHeaders.get("Location");
                	request.setRedirectUrl(newUrl);
                }

                // Some responses such as 204s do not have content.  We must check.
                if (httpResponse.getEntity() != null) {
                  responseContents = entityToBytes(httpResponse.getEntity());
                } else {
                  // Add 0 byte response as a way of honestly representing a
                  // no-content request.
                  responseContents = new byte[0];
                }

                // if the request is slow, log it.
                long requestLifetime = SystemClock.elapsedRealtime() - requestStart;
                logSlowRequests(requestLifetime, request, responseContents, statusLine);

                if (statusCode < 200 || statusCode > 299) {
                    throw new IOException();
                }
                return new NetworkResponse(statusCode, responseContents, responseHeaders, false,
                        SystemClock.elapsedRealtime() - requestStart);
            } catch (SocketTimeoutException e) {
                attemptRetryOnException("socket", request, new TimeoutError());
            } catch (ConnectTimeoutException e) {
                attemptRetryOnException("connection", request, new TimeoutError());
            } catch (MalformedURLException e) {
                throw new RuntimeException("Bad URL " + request.getUrl(), e);
            } catch (IOException e) {
                int statusCode = 0;
                NetworkResponse networkResponse = null;
                if (httpResponse != null) {
                    statusCode = httpResponse.getStatusLine().getStatusCode();
                } else {
                    throw new NoConnectionError(e);
                }
                if (statusCode == HttpStatus.SC_MOVED_PERMANENTLY || 
                		statusCode == HttpStatus.SC_MOVED_TEMPORARILY) {
                	VolleyLog.e("Request at %s has been redirected to %s", request.getOriginUrl(), request.getUrl());
                } else {
                	VolleyLog.e("Unexpected response code %d for %s", statusCode, request.getUrl());
                }
                if (responseContents != null) {
                    networkResponse = new NetworkResponse(statusCode, responseContents,
                            responseHeaders, false, SystemClock.elapsedRealtime() - requestStart);
                    if (statusCode == HttpStatus.SC_UNAUTHORIZED ||
                            statusCode == HttpStatus.SC_FORBIDDEN) {
                        attemptRetryOnException("auth",
                                request, new AuthFailureError(networkResponse));
                    } else if (statusCode == HttpStatus.SC_MOVED_PERMANENTLY || 
                    			statusCode == HttpStatus.SC_MOVED_TEMPORARILY) {
                        attemptRetryOnException("redirect",
                                request, new AuthFailureError(networkResponse));
                    } else {
                        // TODO: Only throw ServerError for 5xx status codes.
                        throw new ServerError(networkResponse);
                    }
                } else {
                    throw new NetworkError(networkResponse);
                }
            }
        }
    }
         这个方法相对来说比较长,我们只挑重点来进行分析,首先第12行之前的部分都是在对我们的请求进行一些初始化的操作,第12行调用HttpStack的performRequest方法,并且将返回结果传给了HttpResponse对象,这里的HttpStack就是我们刚开始在newRequestQueue中创建的HttpStack对象,如果SDK版本号大于9的话,创建的是HurlStack对象,否则的话创建的是HttpClientStack对象,HurlStack内部是使用HttpURLConnection来进行网络请求的,HttpClientStack内部是使用HttpClient进行网络请求的,之后会将HttpResponse对象封装成NetworkResponse对象返回;

        回到我们NetworkDispatcher的run方法的第31行,此时获取到了NetworkResponse对象,第47行我们判断当前Request对象是否允许缓存并且Response对象的结果是否为null,满足条件之后进入if语句块中,将当前的response返回结果添加到缓存中,接下来的过程就和上面CacheDispatcher一样了,首先对NetworkResponse进行解析,随后在第54行通过ExecutorDelivery对象调用postResponse或者在第62行通过ExecutorDelivery对象调用postError将其结果返回给主线程,这点之前上面已经讲过啦!

        上面我们大体上分析了Volley框架的源码,在此做一个小结:

        (1)在Volley中存在三种类型的线程:主线程、缓存线程(1个)、网络请求线程(默认4个)

        (2)在Volly存在两个队列:缓存队列(缓存线程利用死循环从中取出Request请求执行)、网络请求队列(网络请求线程利用死循环从中取出Request请求执行)

        (3)我们在主线程中调用newRequestQueue方法时,同时会发现启动了另外5个线程;

        (4)在主线程中调用RequestQueue的add方法添加一条网络请求之后,该请求默认情况下是会被加入到缓存队列中的,如果在缓存中找到了对应于该请求的缓存结果,则会对其直接进行解析返回,之后调用ExecutorDelivery的postResponse或者postError方法将返回结果传递给主线程,这两个方法会调用Request的deliverResponse和deliverError方法,而这两个方法最终就会分别调用Listener的onResponse方法和ErrorListener的onErrorResponse方法,这也就是我们通常在使用Volley框架的时候需要自己实现的方法了;如果缓存中不存在对应Request的缓存结果或者对应于Request的缓存结果已经过期的话,则会将其添加到网络请求队列中,发送HTTP请求获取响应结果,并对响应结果进行封装、解析、写入缓存,之后呢,再采用和之前一样的方法回调回主线程;

        水平有限,希望大家指正,蟹蟹啦!










  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值