现在来看 loader 怎么写,先看易语言源码怎么写

Universal-ImageLoader源码解析 - [ Android源码解析 ] - 看云
在我们项目中肯定会用到一些第三方的library,有网络框架,图片处理框架等。而我现在常用的就是volley和ImageLoader了,上上篇博客我们把Volley的代码分析了一下,今天我们就来拿一个常用的图片框架——UniversalImageLoader来分析一下。
如何去看源码?我一般都是从公开的使用方法介入,对于今天我们要看的ImageLoader当然就是,
ImageLoader.displayImage();
方法了,我们进入ImageLoader类发现,这当中有好几个重载的displayImage方法,但是不管调用哪一个,最后都会辗转来到,
public void displayImage(String uri, ImageAware imageAware, DisplayImageOptions options,
ImageSize targetSize, ImageLoadingListener listener, ImageLoadingProgressListener progressListener);
方法,理所当然,我们就从这个方法上手开始分析ImageLoader的代码,
public void displayImage(String uri, ImageAware imageAware, DisplayImageOptions options,
ImageSize targetSize, ImageLoadingListener listener, ImageLoadingProgressListener progressListener) {
checkConfiguration();
if (imageAware == null) {
throw new IllegalArgumentException(ERROR_WRONG_ARGUMENTS);
if (listener == null) {
listener = defaultL
if (options == null) {
// 默认的设置
// 通过defaultDisplayImageOptions = DisplayImageOptions.createSimple();
// 而来,具体的默认设置可以看DisplayImageOptions里的代码
options = configuration.defaultDisplayImageO
// 闲的蛋疼给了一个空的uri
if (TextUtils.isEmpty(uri)) {
engine.cancelDisplayTaskFor(imageAware);
// 这里的listener或许是你设置的,一般是默认的
// 回调下载开始
listener.onLoadingStarted(uri, imageAware.getWrappedView());
if (options.shouldShowImageForEmptyUri()) {
// 显示为空时的图片
imageAware.setImageDrawable(options.getImageForEmptyUri(configuration.resources));
imageAware.setImageDrawable(null);
// 回调下载完成
listener.onLoadingComplete(uri, imageAware.getWrappedView(), null);
if (targetSize == null) {
// 封装图片的大小
// 这里的大小是根据Imageview来的
targetSize = ImageSizeUtils.defineTargetSizeForView(imageAware, configuration.getMaxImageSize());
// 生成一个缓存用的key
String memoryCacheKey = MemoryCacheUtils.generateKey(uri, targetSize);
engine.prepareDisplayTaskFor(imageAware, memoryCacheKey);
// 回调开始加载图片
listener.onLoadingStarted(uri, imageAware.getWrappedView());
// 尝试从内存缓存中获取图片
Bitmap bmp = configuration.memoryCache.get(memoryCacheKey);
// 如果获取到图片
// 并且图片没有被回收了
if (bmp != null && !bmp.isRecycled()) {
L.d(LOG_LOAD_IMAGE_FROM_MEMORY_CACHE, memoryCacheKey);
// mark 怎么去处理?
* public boolean shouldPostProcess() {
return postProcessor !=
// 因为是默认的,所以这里返回false
if (options.shouldPostProcess()) {
// ImageLoadingInfo封装了uri
// 该任务的图片大小
// 该任务的设置,监听器等信息
ImageLoadingInfo imageLoadingInfo = new ImageLoadingInfo(uri, imageAware, targetSize, memoryCacheKey,
options, listener, progressListener, engine.getLockForUri(uri));
// 处理并显示图片的任务
// defineHandler 是去获取的一个handler
// 通常是new一个UI线程上的handler
// 这里面的逻辑其实就是当提供了BitmapProcessor时
// 我们可以在图片显示之前去处理一下图片
// 最后还是会走到LoadAndDisplayImageTask
ProcessAndDisplayImageTask displayTask = new ProcessAndDisplayImageTask(engine, bmp, imageLoadingInfo,
defineHandler(options));
if (options.isSyncLoading()) {
displayTask.run(); // 同步的任务,直接调用run方法
// 异步任务, 将任务提交出去
// 从这里我们也可以看出ProcessAndDisplayImageTask
// 实现了了Runnable接口
engine.submit(displayTask);
// 直接显示图片
options.getDisplayer().display(bmp, imageAware, LoadedFrom.MEMORY_CACHE);
listener.onLoadingComplete(uri, imageAware.getWrappedView(), bmp);
// 没有从内存中获取到图片
// 或者图片被标记回收了
if (options.shouldShowImageOnLoading()) {
imageAware.setImageDrawable(options.getImageOnLoading(configuration.resources));
} else if (options.isResetViewBeforeLoading()) {
imageAware.setImageDrawable(null);
ImageLoadingInfo imageLoadingInfo = new ImageLoadingInfo(uri, imageAware, targetSize, memoryCacheKey,
options, listener, progressListener, engine.getLockForUri(uri));
LoadAndDisplayImageTask displayTask = new LoadAndDisplayImageTask(engine, imageLoadingInfo,
defineHandler(options));
if (options.isSyncLoading()) {
displayTask.run();
engine.submit(displayTask);
在代码中注释我已经写的很详细了,这里我们简单的说明一下,imageAware是ImageLoader框架对ImageView的包装,提供了几个便捷的方法供ImageLoader使用,例如ImageViewAware的源码:
// ImageAware其实就是包装了一下ImageView
public class ImageViewAware extends ViewAware {
* Constructor. &br /&
* References {@link #ImageViewAware(ImageView, boolean) ImageViewAware(imageView, true)}.
* @param imageView {@link ImageView ImageView} to work with
public ImageViewAware(ImageView imageView) {
super(imageView);
* Constructor
* @param imageView
{@link ImageView ImageView} to work with
* @param checkActualViewSize &b&true&/b& - then {@link #getWidth()} and {@link #getHeight()} will check actual
size of ImageView. It can cause known issues like
&a href="/nostra13/Android-Universal-Image-Loader/issues/376"&this&/a&.
But it helps to save memory because memory cache keeps bitmaps of actual (less in
general) size.
&b&false&/b& - then {@link #getWidth()} and {@link #getHeight()} will &b&NOT&/b&
consider actual size of ImageView, just layout parameters. &br /& If you set 'false'
it's recommended 'android:layout_width' and 'android:layout_height' (or
'android:maxWidth' and 'android:maxHeight') are set with concrete values. It helps to
save memory.
public ImageViewAware(ImageView imageView, boolean checkActualViewSize) {
super(imageView, checkActualViewSize);
* {@inheritDoc}
* 3) Get &b&maxWidth&/b&.
public int getWidth() {
int width = super.getWidth();
if (width &= 0) {
ImageView imageView = (ImageView) viewRef.get();
if (imageView != null) {
width = getImageViewFieldValue(imageView, "mMaxWidth"); // Check maxWidth parameter
* {@inheritDoc}
* 3) Get &b&maxHeight&/b&
public int getHeight() {
int height = super.getHeight();
if (height &= 0) {
ImageView imageView = (ImageView) viewRef.get();
if (imageView != null) {
height = getImageViewFieldValue(imageView, "mMaxHeight"); // Check maxHeight parameter
public ViewScaleType getScaleType() {
ImageView imageView = (ImageView) viewRef.get();
if (imageView != null) {
return ViewScaleType.fromImageView(imageView);
return super.getScaleType();
public ImageView getWrappedView() {
return (ImageView) super.getWrappedView();
protected void setImageDrawableInto(Drawable drawable, View view) {
((ImageView) view).setImageDrawable(drawable);
if (drawable instanceof AnimationDrawable) {
((AnimationDrawable)drawable).start();
protected void setImageBitmapInto(Bitmap bitmap, View view) {
((ImageView) view).setImageBitmap(bitmap);
private static int getImageViewFieldValue(Object object, String fieldName) {
int value = 0;
Field field = ImageView.class.getDeclaredField(fieldName);
field.setAccessible(true);
int fieldValue = (Integer) field.get(object);
if (fieldValue & 0 && fieldValue & Integer.MAX_VALUE) {
value = fieldV
} catch (Exception e) {
我们继续分析displayImage方法,一个if语句,去判断uri是否为空,当我们无比蛋疼的塞给ImageLoader一个空的Uri的使用这里的逻辑会执行,其实很简单,就是各种回调,还有就是显示我们配置的当数据为空的时的图片,大部分情况下,我们还是会来到这个if下面的代码,
if (targetSize == null) {
// 封装图片的大小
// 这里的大小是根据Imageview来的
targetSize = ImageSizeUtils.defineTargetSizeForView(imageAware, configuration.getMaxImageSize());
这里的作用其实就是为我们的图片准备了一个宽高,看到上面ImageViewAware的代码了,这里面一般都是去调用imageAware.getWidth()和imageAware.getHeight()方法去获取大小。接下来的代码就是试图从内存中获取图片,如果获取到了图片,这里还有一个判断,这个判断是判断我们是不是设置了BitmapProcessor,一般情况下我们是不去设置这个东西的,但是我们还是需要去了解一下这里面都是干了
首先我们包装了一个ImageLoadingInfo,接下来执行了ProcessAndDisplayImageTask的run方法,不管是直接执行run方法还是他被submit了,但肯定都是执行了run方法,区别就是是在当前线程中执行还是其他线程。我们来看看ProcessAndDisplayImageTask的run方法都是干了
public void run() {
L.d(LOG_POSTPROCESS_IMAGE, imageLoadingInfo.memoryCacheKey);
// 没有找到默认的Processor
// BitmapProcessor是一个接口
// 只有一个process方法,目的是在显示之前允许我们处理一下图片
// 那这里不会报nullpointer吗?
// 当然不是,因为在ImageLoader.displayImage中 submit该任务之前去判断了
BitmapProcessor processor = imageLoadingInfo.options.getPostProcessor();
Bitmap processedBitmap = processor.process(bitmap);
DisplayBitmapTask displayBitmapTask = new DisplayBitmapTask(processedBitmap, imageLoadingInfo, engine,
LoadedFrom.MEMORY_CACHE);
LoadAndDisplayImageTask.runTask(displayBitmapTask, imageLoadingInfo.options.isSyncLoading(), handler, engine);
就是调用了BitmapProcessor的process方法处理一下图片,至于怎么处理,完全取决于我们怎么实现BitmapProcessor接口了,这也就允许我们在现实图片之前处理一下图片,接下来的关键肯定就是DisplayBitmapTask了,我们来看一下这个类的run方法,
public void run() {
// 各种Listener的回调
if (imageAware.isCollected()) {
L.d(LOG_TASK_CANCELLED_IMAGEAWARE_COLLECTED, memoryCacheKey);
listener.onLoadingCancelled(imageUri, imageAware.getWrappedView());
} else if (isViewWasReused()) {
L.d(LOG_TASK_CANCELLED_IMAGEAWARE_REUSED, memoryCacheKey);
listener.onLoadingCancelled(imageUri, imageAware.getWrappedView());
L.d(LOG_DISPLAY_IMAGE_IN_IMAGEAWARE, loadedFrom, memoryCacheKey);
// 通过设置不同的displayer来显示不同样式的图片
displayer.display(bitmap, imageAware, loadedFrom);
engine.cancelDisplayTaskFor(imageAware);
listener.onLoadingComplete(imageUri, imageAware.getWrappedView(), bitmap);
这里的主要的最用就是回调然后调用BitmapDisplayer来显示图片,至于BitmapDisplayer怎么去显示,当然是调用ImageAware的方法去给我们传进来的ImageView设置图片啦。
好,继续回到之前的代码,继续看else,如果我们没有提供BitmapProcessor,那么直接显示图片,现在讲解的是从内存中获取了图片,那获取不到呢?我们继续看看else里是怎么做的,
// 没有从内存中获取到图片
// 或者图片被标记回收了
if (options.shouldShowImageOnLoading()) {
imageAware.setImageDrawable(options.getImageOnLoading(configuration.resources));
} else if (options.isResetViewBeforeLoading()) {
imageAware.setImageDrawable(null);
ImageLoadingInfo imageLoadingInfo = new ImageLoadingInfo(uri, imageAware, targetSize, memoryCacheKey,
options, listener, progressListener, engine.getLockForUri(uri));
LoadAndDisplayImageTask displayTask = new LoadAndDisplayImageTask(engine, imageLoadingInfo,
defineHandler(options));
if (options.isSyncLoading()) {
displayTask.run();
engine.submit(displayTask);
首先如果我们设置了加载中的图片,这里会显示加载中的图片,然后封装了一个ImageLoadingInfo和LoadAndDisplayImageTask,并且执行它,我们相信,很多关键的代码都在LoadAndDisplayImageTask当中,而且是在他的run方法中!
final class LoadAndDisplayImageTask implements Runnable, IoUtils.CopyListener {
public void run() {
if (waitIfPaused())
if (delayIfNeed())
// 得到互斥锁
// 对于Lock不熟悉的朋友可以去看java的Lock部分
ReentrantLock loadFromUriLock = imageLoadingInfo.loadFromUriL
L.d(LOG_START_DISPLAY_IMAGE_TASK, memoryCacheKey);
if (loadFromUriLock.isLocked()) {
L.d(LOG_WAITING_FOR_IMAGE_LOADED, memoryCacheKey);
loadFromUriLock.lock();
checkTaskNotActual();
// 尝试从内存缓存中获取图片
bmp = configuration.memoryCache.get(memoryCacheKey);
// 如果图片为空,或者图片标记回收了
if (bmp == null || bmp.isRecycled()) {
// mark 这里面干了吗?
bmp = tryLoadBitmap();
if (bmp == null) // listener callback already was fired
checkTaskNotActual();
checkTaskInterrupted();
if (options.shouldPreProcess()) {
L.d(LOG_PREPROCESS_IMAGE, memoryCacheKey);
bmp = options.getPreProcessor().process(bmp);
if (bmp == null) {
L.e(ERROR_PRE_PROCESSOR_NULL, memoryCacheKey);
// 缓存到内存中
if (bmp != null && options.isCacheInMemory()) {
L.d(LOG_CACHE_IMAGE_IN_MEMORY, memoryCacheKey);
configuration.memoryCache.put(memoryCacheKey, bmp);
loadedFrom = LoadedFrom.MEMORY_CACHE;
L.d(LOG_GET_IMAGE_FROM_MEMORY_CACHE_AFTER_WAITING, memoryCacheKey);
if (bmp != null && options.shouldPostProcess()) {
L.d(LOG_POSTPROCESS_IMAGE, memoryCacheKey);
bmp = options.getPostProcessor().process(bmp);
if (bmp == null) {
L.e(ERROR_POST_PROCESSOR_NULL, memoryCacheKey);
checkTaskNotActual();
checkTaskInterrupted();
} catch (TaskCancelledException e) {
fireCancelEvent();
} finally {
loadFromUriLock.unlock();
DisplayBitmapTask displayBitmapTask = new DisplayBitmapTask(bmp, imageLoadingInfo, engine, loadedFrom);
runTask(displayBitmapTask, syncLoading, handler, engine);
首先我们获取了一个锁,这里为什么要用锁呢?别忘了,大多数情况下,我们是异步加载图片,这里可能是在不同的线程中去加载图片,加锁的目的就是为了保证每次处理完成了以后其他的处理才能得以进行,关键代码其实就是bmp = tryLoadBitmap();这一句,这个tryLoadBitmap里面到底干了什么呢?
private Bitmap tryLoadBitmap() throws TaskCancelledException {
Bitmap bitmap =
// 从磁盘缓存中获取图片
File imageFile = configuration.diskCache.get(uri);
// 如果文件存在
if (imageFile != null && imageFile.exists() && imageFile.length() & 0) {
L.d(LOG_LOAD_IMAGE_FROM_DISK_CACHE, memoryCacheKey);
loadedFrom = LoadedFrom.DISC_CACHE;
checkTaskNotActual();
// 直接从磁盘中加载图片
bitmap = decodeImage(Scheme.FILE.wrap(imageFile.getAbsolutePath()));
// 如果图片不存在
if (bitmap == null || bitmap.getWidth() &= 0 || bitmap.getHeight() &= 0) {
L.d(LOG_LOAD_IMAGE_FROM_NETWORK, memoryCacheKey);
loadedFrom = LoadedFrom.NETWORK;
String imageUriForDecoding =
// 将图片缓存到磁盘
// tryCacheImageOnDisk去加载了网络图片
// mark tryCacheImageOnDisk的实现
if (options.isCacheOnDisk() && tryCacheImageOnDisk()) {
// 加载成功了, 则获取本地图片文件
imageFile = configuration.diskCache.get(uri);
if (imageFile != null) {
// 将uri替换成图片在本地磁盘地址
imageUriForDecoding = Scheme.FILE.wrap(imageFile.getAbsolutePath());
checkTaskNotActual();
// 如果上面允许缓存, 则在上面就加载了图片
// 并且将imageUriForDecoding替换成了本地uri
// 否则这里是去加载网络图片
bitmap = decodeImage(imageUriForDecoding);
if (bitmap == null || bitmap.getWidth() &= 0 || bitmap.getHeight() &= 0) {
fireFailEvent(FailType.DECODING_ERROR, null);
} catch (IllegalStateException e) {
fireFailEvent(FailType.NETWORK_DENIED, null);
} catch (TaskCancelledException e) {
} catch (IOException e) {
fireFailEvent(FailType.IO_ERROR, e);
} catch (OutOfMemoryError e) {
fireFailEvent(FailType.OUT_OF_MEMORY, e);
} catch (Throwable e) {
fireFailEvent(FailType.UNKNOWN, e);
这里才是加载图片的关键,首先去判断磁盘中是否存在图片,如果存在,则直接从磁盘加载图片,接下来,如果本地没有,那就到了最后冲刺的阶段了,到了从网络获取图片的时候了,一个tryCacheImageOnDisk方法搞定了图片从网络的获取,怎么去网络获取图片我们先放一放,我们继续看代码,如果图片下载成功了,那本地肯定有了新的图片,那么我们就将uri替换成本地的uri,为啥要替换?我们继续分析代码,等分析完了,你再回头来看,就明白什么意思了!接下来的代码就很简单了,就是在合适的时机将图片缓存起来。那么关键,我们就来看看图片怎么下载下来的吧。
private boolean tryCacheImageOnDisk() throws TaskCancelledException {
L.d(LOG_CACHE_IMAGE_ON_DISK, memoryCacheKey);
loaded = downloadImage();
if (loaded) {
int width = configuration.maxImageWidthForDiskC
int height = configuration.maxImageHeightForDiskC
if (width & 0 || height & 0) {
L.d(LOG_RESIZE_CACHED_IMAGE_FILE, memoryCacheKey);
resizeAndSaveImage(width, height); // TODO : process boolean result
} catch (IOException e) {
这里面的关键就是downloadImage了,我们继续看看downloadImage,
private boolean downloadImage() throws IOException {
InputStream is = getDownloader().getStream(uri, options.getExtraForDownloader());
if (is == null) {
L.e(ERROR_NO_IMAGE_STREAM, memoryCacheKey);
return configuration.diskCache.save(uri, is, this);
} finally {
IoUtils.closeSilently(is);
直接从Downloader中获取一个stream,然后调用磁盘缓存类的save方法,先看看怎么save的?
BaseDiskCache.save方法,
public boolean save(String imageUri, InputStream imageStream, IoUtils.CopyListener listener) throws IOException {
File imageFile = getFile(imageUri);
File tmpFile = new File(imageFile.getAbsolutePath() + TEMP_IMAGE_POSTFIX);
boolean loaded =
OutputStream os = new BufferedOutputStream(new FileOutputStream(tmpFile), bufferSize);
loaded = IoUtils.copyStream(imageStream, os, listener, bufferSize);
} finally {
IoUtils.closeSilently(os);
} finally {
if (loaded && !tmpFile.renameTo(imageFile)) {
if (!loaded) {
tmpFile.delete();
这里主要就是将从网络获取到stream写入到本地。可以看到这几个操作我们唯一可以用来区分的就是那个作为key的uri了,也正是因为uri,我们可以保证在图片下载完成后,可以继续从本地中获取图片然后设置大小并且显示。
那上面的那个ImageDownloader是个啥呢?我们随便来看一个ImageDownloader吧,也是ImageLoader唯一提供的一个实现了的ImageDownloader——BaseImageDownloader,
public class BaseImageDownloader implements ImageDownloader {
public InputStream getStream(String imageUri, Object extra) throws IOException {
// 根据不同的scheme去调用不同的方法
switch (Scheme.ofUri(imageUri)) {
case HTTP:
case HTTPS:
return getStreamFromNetwork(imageUri, extra);
case FILE:
return getStreamFromFile(imageUri, extra);
case CONTENT:
return getStreamFromContent(imageUri, extra);
case ASSETS:
return getStreamFromAssets(imageUri, extra);
case DRAWABLE:
return getStreamFromDrawable(imageUri, extra);
case UNKNOWN:
return getStreamFromOtherSource(imageUri, extra);
这里面根据不同的scheme来从不同的途径来获取图片,我们现在只关心从网络获取部分,
protected InputStream getStreamFromNetwork(String imageUri, Object extra) throws IOException {
HttpURLConnection conn = createConnection(imageUri, extra);
int redirectCount = 0;
while (conn.getResponseCode() / 100 == 3 && redirectCount & MAX_REDIRECT_COUNT) {
conn = createConnection(conn.getHeaderField("Location"), extra);
redirectCount++;
InputStream imageS
imageStream = conn.getInputStream();
} catch (IOException e) {
// Read all data to allow reuse connection (http://bit.ly/1ad35PY)
IoUtils.readAndCloseStream(conn.getErrorStream());
if (!shouldBeProcessed(conn)) {
IoUtils.closeSilently(imageStream);
throw new IOException("Image request failed with response code " + conn.getResponseCode());
return new ContentLengthInputStream(new BufferedInputStream(imageStream, BUFFER_SIZE), conn.getContentLength());
这里就不再多说了,就是去调用HttpUrlConnection实现网络图片的获取,并且获取一个Stream。
好了,现在图片下载到本地了,我们还有一个decoder.decode没看,
private Bitmap decodeImage(String imageUri) throws IOException {
ViewScaleType viewScaleType = imageAware.getScaleType();
ImageDecodingInfo decodingInfo = new ImageDecodingInfo(memoryCacheKey, imageUri, uri, targetSize, viewScaleType,
getDownloader(), options);
return decoder.decode(decodingInfo);
来看一个Decoder的实现,BaseImageDeocder,
public Bitmap decode(ImageDecodingInfo decodingInfo) throws IOException {
Bitmap decodedB
ImageFileInfo imageI
// 这里面会根据就scheme去从不同的地方加载图片
// 从网络加载的部分利用HttpUrlConnection
// 最后返回一个InputStream
InputStream imageStream = getImageStream(decodingInfo);
if (imageStream == null) {
L.e(ERROR_NO_IMAGE_STREAM, decodingInfo.getImageKey());
// 获取图片的宽高和exif信息
imageInfo = defineImageSizeAndRotation(imageStream, decodingInfo);
// 还原stream
// 什么叫还原? 因为通过上面的操作,我们的stream游标可能已经
// 已经不再首部,这时再去读取,还是继续读取,造成了信息不完成
// 所以这里需要reset一下
imageStream = resetStream(imageStream, decodingInfo);
// 主要就是判断图片大小
Options decodingOptions = prepareDecodingOptions(imageInfo.imageSize, decodingInfo);
// 通过stream decode出Bitmap
decodedBitmap = BitmapFactory.decodeStream(imageStream, null, decodingOptions);
} finally {
IoUtils.closeSilently(imageStream);
if (decodedBitmap == null) {
L.e(ERROR_CANT_DECODE_IMAGE, decodingInfo.getImageKey());
decodedBitmap = considerExactScaleAndOrientatiton(decodedBitmap, decodingInfo, imageInfo.exif.rotation,
imageInfo.exif.flipHorizontal);
return decodedB
这里面我们根据我们传进来的stream来生成一个Bitmap对象,这里是调用了我们熟悉的代码BitmapFactory.decodeStream来decode出了一个Bitmap对象,最后返回。这一切的一切完成以后,我们继续看看LoadAndDisplayBitmapTask的run方法,这里还有一些工作没有完成,
public void run() {
DisplayBitmapTask displayBitmapTask = new DisplayBitmapTask(bmp, imageLoadingInfo, engine, loadedFrom);
runTask(displayBitmapTask, syncLoading, handler, engine);
这里最终还是去执行了DisplayBitmapTask,将我们最后获取到的Bitmap显示到界面上,关于DisplayBitmapTask,我们在前面已经讲解过了,大家可以去翻看前面的内容。
好了,到现在我们整个主流程都跑通了,但是还有很多细节没有说,感兴趣的朋友可以打开源码去看一下。
参考资料:
页面正在加载中}

我要回帖

更多关于 易语言源码怎么写 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信