作者 | doubimonkey

地址 | http://www.jianshu.com/p/419e7c25d1ba

声明 | 本文是 doubimonkey 原创,已获授权发布,未经原作者允许请勿转载

前言

目前在 app 上通过记录用户操作(俗称埋点),来分析用户行为的做法,已经成了 app 必不可少的一部分。有关 app 的埋点技术,也在发展中。正好最近项目组研发了一个埋点的 sdk,所以把相关知识梳理下。

埋点方式

代码埋点

这种方式主要是由程序猿们手动在代码中的回调事件里加上埋点代码。优点是高度定制,想怎么埋怎么埋,缺点是工作量大,而且易出错,难维护。

可视化埋点

这种埋点方式分为两种,一是使用后台界面配置需要埋点的位置,app下载配置文件,将需要埋点的事件上传(代表 MixPanel,百度,talkingdata等)。二是app把所有事件上传,后台自己选择需要埋点的点(代表heap)。

两种埋点方式各有优劣,但是由于技术目前还在发展中,并没有形成完全统一的理论以及方式,因此现在大多是这两种方式并存。

参考文献: http://blog.csdn.net/vshuang/article/details/60361314

MixPanel源码分析-Android

下面是分析 MixPanel 的源码,这应该是唯一的开源的商业埋点实现(其他我没找到),提供可视化埋点以及代码埋点方式。开源地址:https://github.com/mixpanel ,我主要是研究 Android 的代码。

本文的分析并不透彻,主要由于 mixpanel 的代码比较复杂,很多是和服务器的交互,在不了解协议的情况下,我也是连蒙带猜来看源码的。想透彻分析的同学,可以在 mixpanel 的网站上注册一个应用,再在应用里集成 mixpanel 的源码,然后加日志或者 debug 来分析。由于时间有限,我并没有这么做。请见谅。

首先是 mixpanel 的入口,MixPanelApi

该类中有大量的方法叫做 Tweak,这个是用来做 abtest 的,在服务器上做相应的配置,客户端可以拉取配置实现不同的功能。本文不讨论这个。

主要方法就是 track,

/** * Track an event. * * <p>Every call to track eventually results in a data point sent to Mixpanel. These data points * are what are measured, counted, and broken down to create your Mixpanel reports. Events * have a string name, and an optional set of name/value pairs that describe the properties of * that event. * * @param eventName The name of the event to send * @param properties A JSONObject containing the key value pairs of the properties to include in this event. *                   Pass null if no extra properties exist. */public void track(String eventName, JSONObject properties) {    track(eventName, properties, false);}

我们通过不停跟踪代码发现,这个方法会把埋点的 event,生成一个AnalyticsMessages.EventDescription 对象,然后通过 handler,发送到后台线程中去处理,代码如下

track(){  final AnalyticsMessages.EventDescription eventDescription =    new AnalyticsMessages.EventDescription(eventName, messageProps, mToken, isAutomaticEvent);  mMessages.eventsMessage(eventDescription);}// ...跳转至eventsMessagepublic void eventsMessage(final EventDescription eventDescription) {        final Message m = Message.obtain();        m.what = ENQUEUE_EVENTS;        m.obj = eventDescription;        mWorker.runMessage(m);}//消息处理if (msg.what == ENQUEUE_EVENTS) {  final EventDescription eventDescription = (EventDescription) msg.obj;  try {    //省略部分代码    returnCode = mDbAdapter.addJSON(message, token, MPDbAdapter.Table.EVENTS, eventDescription.isAutomatic());  } catch (final JSONException e) {    MPLog.e(LOGTAG, "Exception tracking event " + eventDescription.getEventName(), e);  }}

可以看到,最终数据被存储到了数据库里,具体的数据库表结构大家可以自行看源码,我就不研究哦了。

那数据什么时候上传呢,主要是在 activiyt 的 onPause 之后上传。

@Overridepublic void onActivityPaused(final Activity activity) {    mPaused = true;    if (check != null) {        mHandler.removeCallbacks(check);    }    mHandler.postDelayed(check = new Runnable(){        @Override        public void run() {            if (mIsForeground && mPaused) {                mIsForeground = false;                try {                    double sessionLength = System.currentTimeMillis() - sStartSessionTime;                    if (sessionLength >= mConfig.getMinimumSessionDuration() && sessionLength < mConfig.getSessionTimeoutDuration()) {                        DecimalFormat df = new DecimalFormat("#.0");                        String sessionLengthString = df.format((System.currentTimeMillis() - sStartSessionTime) / 1000);                        JSONObject sessionProperties = new JSONObject();                        sessionProperties.put(AutomaticEvents.SESSION_LENGTH, sessionLengthString);                        mMpInstance.track(AutomaticEvents.SESSION, sessionProperties, true);                    }                } catch (JSONException e) {                    e.printStackTrace();                }                mMpInstance.flush(); //上传            }        }    }, CHECK_DELAY);}

用户也可以通过 MixPanelApi 的 flush 方法上传

public void flush() {    mMessages.postToServer(new AnalyticsMessages.FlushDescription(mToken));}

这就是事件埋点的基本流程,当然功能不止这些,还可以通过 activity 的生命周期,记录页面停留时长等,这些都是基于这个基本流程来处理的。

可视化埋点

我觉得埋点主要的难点就是在可视化埋点上,如何做到良好的用户体验以及性能呢。我们一起来看看 MixPanel 是怎么做的。

首先看一下官网的介绍 https://mixpanel.com/autotrack/

通过视频可以看到,网页后台可以找到我们所有可以埋点的区域,该区域会高亮+边框显示出来,点击该区域,就会显示弹出一个对话框,就可以把这个区域射成一个埋点的位置。

这看起来是不是比代码埋点好多啦。那服务器是怎么找到 app 中可以埋点的位置的呢。我们来看一下源码,首先是连接上配置界面的地方,是通过 websocket 连接的,mixpanel 继承了大量 websocket 的实现,这里我们就不管他了,感兴趣的同学可以去自己研究下 websocket 的开源实现。具体处理协议的地方是 EditorClient 这个类

private class EditorClient extends WebSocketClient {    public EditorClient(URI uri, int connectTimeout, Socket sslSocket) throws InterruptedException {        super(uri, new Draft_17(), null, connectTimeout);        setSocket(sslSocket);    }    @Override    public void onOpen(ServerHandshake handshakedata) {        MPLog.v(LOGTAG, "Websocket connected");    }    @Override    public void onMessage(String message) {        MPLog.v(LOGTAG, "Received message from editor:\n" + message);        try {            final JSONObject messageJson = new JSONObject(message);            final String type = messageJson.getString("type");            if (type.equals("device_info_request")) {                mService.sendDeviceInfo();            } else if (type.equals("snapshot_request")) {                mService.sendSnapshot(messageJson);            } else if (type.equals("change_request")) {                mService.performEdit(messageJson);            } else if (type.equals("event_binding_request")) {                mService.bindEvents(messageJson);            } else if (type.equals("clear_request")) {                mService.clearEdits(messageJson);            } else if (type.equals("tweak_request")) {                mService.setTweaks(messageJson);            }        } catch (final JSONException e) {            MPLog.e(LOGTAG, "Bad JSON received:" + message, e);        }    }

可以看到 OnMessage 的地方有这么几个接口。这些都是后台 web 页面发过来的消息,然后 app 端执行相应的操作。

device_info_request 这个就不说了,显然是获取一些设备的信息。

snapshot_request 这个就是关键的地方,这里是 app 端将当前展示的页面的截图,发送给后端,这样后端就可以显示出来了。我们通过代码跟踪,找到了实现在 ViewCrawler 里的 sendSnapshot 方法。

private void sendSnapshot(JSONObject message) {    final long startSnapshot = System.currentTimeMillis();    //...省略    try {        writer.write("{");        writer.write("\"type\": \"snapshot_response\",");        writer.write("\"payload\": {");        {            writer.write("\"activities\":");            writer.flush();            mSnapshot.snapshots(mEditState, out);        }        final long snapshotTime = System.currentTimeMillis() - startSnapshot;        writer.write(",\"snapshot_time_millis\": ");        writer.write(Long.toString(snapshotTime));        writer.write("}"); // } payload        writer.write("}"); // } whole message    } catch (final IOException e) {        MPLog.e(LOGTAG, "Can't write snapshot request to server", e);    } finally {        try {            writer.close();        } catch (final IOException e) {            MPLog.e(LOGTAG, "Can't close writer.", e);        }    }}

关键代码在 ViewSnapShot 里

/** * Take a snapshot of each activity in liveActivities. The given UIThreadSet will be accessed * on the main UI thread, and should contain a set with elements for every activity to be * snapshotted. Given stream out will be written on the calling thread. */public void snapshots(UIThreadSet<Activity> liveActivities, OutputStream out) throws IOException {    mRootViewFinder.findInActivities(liveActivities);    final FutureTask<List<RootViewInfo>> infoFuture = new FutureTask<List<RootViewInfo>>(mRootViewFinder);    mMainThreadHandler.post(infoFuture);    final OutputStreamWriter writer = new OutputStreamWriter(out);    List<RootViewInfo> infoList = Collections.<RootViewInfo>emptyList();    writer.write("[");    try {        infoList = infoFuture.get(1, TimeUnit.SECONDS);    } catch (final InterruptedException e) {        MPLog.d(LOGTAG, "Screenshot interrupted, no screenshot will be sent.", e);    } catch (final TimeoutException e) {        MPLog.i(LOGTAG, "Screenshot took more than 1 second to be scheduled and executed. No screenshot will be sent.", e);    } catch (final ExecutionException e) {        MPLog.e(LOGTAG, "Exception thrown during screenshot attempt", e);    }

RootViewInfo 是一个 Future,主要的方法是 taskSnapShot

private void takeScreenshot(final RootViewInfo info) {    final View rootView = info.rootView;    Bitmap rawBitmap = null;    try {        final Method createSnapshot = View.class.getDeclaredMethod("createSnapshot", Bitmap.Config.class, Integer.TYPE, Boolean.TYPE);        createSnapshot.setAccessible(true);        rawBitmap = (Bitmap) createSnapshot.invoke(rootView, Bitmap.Config.RGB_565, Color.WHITE, false);    } catch (final NoSuchMethodException e) {        MPLog.v(LOGTAG, "Can't call createSnapshot, will use drawCache", e);    } catch (final IllegalArgumentException e) {        MPLog.d(LOGTAG, "Can't call createSnapshot with arguments", e);    } catch (final InvocationTargetException e) {        MPLog.e(LOGTAG, "Exception when calling createSnapshot", e);    } catch (final IllegalAccessException e) {        MPLog.e(LOGTAG, "Can't access createSnapshot, using drawCache", e);    } catch (final ClassCastException e) {        MPLog.e(LOGTAG, "createSnapshot didn't return a bitmap?", e);    }    Boolean originalCacheState = null;    try {        if (null == rawBitmap) {            originalCacheState = rootView.isDrawingCacheEnabled();            rootView.setDrawingCacheEnabled(true);            rootView.buildDrawingCache(true);            rawBitmap = rootView.getDrawingCache();        }    } catch (final RuntimeException e) {        MPLog.v(LOGTAG, "Can't take a bitmap snapshot of view " + rootView + ", skipping for now.", e);    }    float scale = 1.0f;    if (null != rawBitmap) {        final int rawDensity = rawBitmap.getDensity();        if (rawDensity != Bitmap.DENSITY_NONE) {            scale = ((float) mClientDensity) / rawDensity;        }        final int rawWidth = rawBitmap.getWidth();        final int rawHeight = rawBitmap.getHeight();        final int destWidth = (int) ((rawBitmap.getWidth() * scale) + 0.5);        final int destHeight = (int) ((rawBitmap.getHeight() * scale) + 0.5);        if (rawWidth > 0 && rawHeight > 0 && destWidth > 0 && destHeight > 0) {            mCachedBitmap.recreate(destWidth, destHeight, mClientDensity, rawBitmap);        }    }    if (null != originalCacheState && !originalCacheState) {        rootView.setDrawingCacheEnabled(false);    }    info.scale = scale;    info.screenshot = mCachedBitmap;}

这里就知道了,首先通过反射 view 的 createSnapshot 方法,尝试获取view的截图,如果没有成功,则调用截屏的api,来获取Drawingcache。获取到之后,根据当前手机屏幕的分辨率来缩放一次。保证跟手机的分辨率一致。再上传给服务器,当然这里只是屏幕的截图,只根据截图,是无法知道控件点击位置的。然后又做了什么呢,我们继续看代码:

private void snapshotView(JsonWriter j, View view)            throws IOException {        final int viewId = view.getId();        final String viewIdName;        if (-1 == viewId) {            viewIdName = null;        } else {            viewIdName = mResourceIds.nameForId(viewId);        }        j.beginObject();        j.name("hashCode").value(view.hashCode());        j.name("id").value(viewId);        j.name("mp_id_name").value(viewIdName);        final CharSequence description = view.getContentDescription();        if (null == description) {            j.name("contentDescription").nullValue();        } else {            j.name("contentDescription").value(description.toString());        }        final Object tag = view.getTag();        if (null == tag) {            j.name("tag").nullValue();        } else if (tag instanceof CharSequence) {            j.name("tag").value(tag.toString());        }        j.name("top").value(view.getTop());        j.name("left").value(view.getLeft());        j.name("width").value(view.getWidth());        j.name("height").value(view.getHeight());        j.name("scrollX").value(view.getScrollX());        j.name("scrollY").value(view.getScrollY());        j.name("visibility").value(view.getVisibility());        float translationX = 0;        float translationY = 0;        if (Build.VERSION.SDK_INT >= 11) {            translationX = view.getTranslationX();            translationY = view.getTranslationY();        }        j.name("translationX").value(translationX);        j.name("translationY").value(translationY);        j.name("classes");        j.beginArray();       // ..省略部分        if (view instanceof ViewGroup) {            final ViewGroup group = (ViewGroup) view;            final int childCount = group.getChildCount();            for (int i = 0; i < childCount; i++) {                final View child = group.getChildAt(i);                // child can be null when views are getting disposed.                if (null != child) {                    snapshotView(j, child);                }            }        }    }

这里就是关键,通过遍历当前 view,将所有的 view 的信息,都传给了后端,尤其是 top,left,width,height 这些信息,通过这些信息,后端就可以确定 view 的位置。这里我觉得是有优化空间的,完全可以只上传可以被埋点的 view,例如 button 等,像一些纯展示的无法点击的view,其实没必要上传的。例如大部分 textview。这样增加了后端的负担。

当在后台操作,选择了一个点击的 view 之后,app 端就会收到event_binding_request 消息,我们来看看如何处理这个消息的

final int size = mEditorEventBindings.size();for (int i = 0; i < size; i++) {    final Pair<String, JSONObject> changeInfo = mEditorEventBindings.get(i);    try {        final ViewVisitor visitor = mProtocol.readEventBinding(changeInfo.second, mDynamicEventTracker);        newVisitors.add(new Pair<String, ViewVisitor>(changeInfo.first, visitor));    } catch (final EditProtocol.InapplicableInstructionsException e) {        MPLog.i(LOGTAG, e.getMessage());    } catch (final EditProtocol.BadInstructionsException e) {        MPLog.e(LOGTAG, "Bad editor event binding cannot be applied.", e);    }}

首先调用 readEventBinding 读取服务端发来的信息,然后将它存在一个ViewVisitor 里,想找到具体的 view,则需要识别 view 的一些基本的特征,之前的代码已经看到了,view 的 id,tag,contentdescription 等特征,都已经被上传至服务器,这时服务器又会把它下发回来,放在 path 参数里。

// Package access FOR TESTING ONLY/* package */ List<Pathfinder.PathElement> readPath(JSONArray pathDesc, ResourceIds idNameToId) throws JSONException {    final List<Pathfinder.PathElement> path = new ArrayList<Pathfinder.PathElement>();    for (int i = 0; i < pathDesc.length(); i++) {        final JSONObject targetView = pathDesc.getJSONObject(i);        final String prefixCode = JSONUtils.optionalStringKey(targetView, "prefix");        final String targetViewClass = JSONUtils.optionalStringKey(targetView, "view_class");        final int targetIndex = targetView.optInt("index", -1);        final String targetDescription = JSONUtils.optionalStringKey(targetView, "contentDescription");        final int targetExplicitId = targetView.optInt("id", -1);        final String targetIdName = JSONUtils.optionalStringKey(targetView, "mp_id_name");        final String targetTag = JSONUtils.optionalStringKey(targetView, "tag");        final int prefix;        if ("shortest".equals(prefixCode)) {            prefix = Pathfinder.PathElement.SHORTEST_PREFIX;        } else if (null == prefixCode) {            prefix = Pathfinder.PathElement.ZERO_LENGTH_PREFIX;        } else {            MPLog.w(LOGTAG, "Unrecognized prefix type \"" + prefixCode + "\". No views will be matched");            return NEVER_MATCH_PATH;        }        final int targetId;        final Integer targetIdOrNull = reconcileIds(targetExplicitId, targetIdName, idNameToId);        if (null == targetIdOrNull) {            return NEVER_MATCH_PATH;        } else {            targetId = targetIdOrNull.intValue();        }        path.add(new Pathfinder.PathElement(prefix, targetViewClass, targetIndex, targetId, targetDescription, targetTag));    }    return path;}

通过这些参数,就可以找个那个 view,继而对 view 进行监听,监听的方式就比较简单了,用谷歌提供的 Accessibility 相关 api 就可以做到。

if ("click".equals(eventType)) {    return new ViewVisitor.AddAccessibilityEventVisitor(        path,        AccessibilityEvent.TYPE_VIEW_CLICKED,        eventName,        listener    );} else if ("selected".equals(eventType)) {    return new ViewVisitor.AddAccessibilityEventVisitor(        path,        AccessibilityEvent.TYPE_VIEW_SELECTED,        eventName,        listener    );} else if ("text_changed".equals(eventType)) {    return new ViewVisitor.AddTextChangeListener(path, eventName, listener);} else if ("detected".equals(eventType)) {    return new ViewVisitor.ViewDetectorVisitor(path, eventName, listener);} else {    throw new BadInstructionsException("Mixpanel can't track event type \"" + eventType + "\"");}

而匹配 view 的规则也很简单,就是对比 class,id,contentDescription,tag 四个元素

private boolean matches(PathElement matchElement, View subject) {    if (null != matchElement.viewClassName &&            !hasClassName(subject, matchElement.viewClassName)) {        return false;    }    if (-1 != matchElement.viewId && subject.getId() != matchElement.viewId) {        return false;    }    if (null != matchElement.contentDescription &&            !matchElement.contentDescription.equals(subject.getContentDescription())) {        return false;    }    final String matchTag = matchElement.tag;    if (null != matchElement.tag) {        final Object subjectTag = subject.getTag();        if (null == subjectTag || !matchTag.equals(subject.getTag().toString())) {            return false;        }    }    return true;}

这样,在每次 app 的 activity 的 onresume 方法里,都会去做这个寻找匹配的 view 的过程,具体看 viewcrawler 的 onActivityResume 方法

@Override //ViewCrawler.classpublic void onActivityResumed(Activity activity) {    installConnectionSensor(activity);    mEditState.add(activity);}//通过一系列处理,最终调用了visit方法/**     * Scans the View hierarchy below rootView, applying it's operation to each matching child view.     */public void visit(View rootView) {  Log.d(LOGTAG, mPath.get(0).toString());  mPathfinder.findTargetsInRoot(rootView, mPath, this);}

这种做法的效率暂且不停,到这里我们的流程就分析完了。大致的流程就是

App 端 上传屏幕截图和页面布局信息-》服务端操作之后,下发需要埋点的 viewpath -》 app 端存储这个 path,并在每个 activity 的 onResume 都去执行寻找 path 的任务-》注册 Accessibility 监听,上传相应事件。

这样就实现了可视化埋点,但是这种方式,应该是用于已经发布到线上的app 的埋点,而且不同版本不通用。。。因为 view 的 id 等信息是会随着版本变化的。如果这里有错误,请大神指出。不胜感激,谢谢!!

与之相关

1 程序员之路-学习经验总结分享

2 Android 仿今日头条的开源项目

MixPanel -Android 端埋点技术研究相关推荐

  1. 白皮书 | 国内首份《Android 全埋点技术白皮书》开源所有项目源码!

    随着大数据行业的快速发展,越来越多的人们意识到--数据基础夯实与否,取决于数据的采集方式. 目前,国内大数据埋点方式多样,按照埋点位置不同,可以分为前端(客户端)埋点与后端(服务器端)埋点.其中全埋点 ...

  2. 重磅!《Android 全埋点技术白皮书》开源所有项目源码!

    随着大数据行业的快速发展,越来越多的人们意识到--数据基础夯实与否,取决于数据的采集方式. 目前,国内大数据埋点方式多样,按照埋点位置不同,可以分为前端(客户端)埋点与后端(服务器端)埋点.其中全埋点 ...

  3. 图形引擎实战:手游Android端后台下载技术分享

    一.功能特点 手游android端后台下载SDK是畅游自主研发的一款移动平台android端后台文件下载工具包,它主要提供网络文件的后台下载功能,功能完善,性能高,可以满足游戏制作有关后台下载文件的需 ...

  4. android百度室内定位吗,基于Android的室内定位技术研究

    摘要: 随着智能移动终端的普及和发展,基于位置的服务(LBS,Location Based Service)成为智能终端发展的新热点.LBS的关键技术是定位技术,定位技术的精确度对于LBS来说至关重要 ...

  5. opencv 全景 android,基于OpenCV的Android手机全景图像拼接技术研究

    摘要: 21世纪是信息化时代,人们每天被各式各样的信息所包围,这些信息中90%来自于图像.在生活中很多情况下,人们需要记录某些场景,常用的工具就是相机,包括带拍照功能的智能手机.随着科技的发展,软硬件 ...

  6. 重磅新书 | 《Android 全埋点解决方案》预售正式开启!

    新书抢先看 这是一本实战为导向的.翔实的 Android 全埋点技术与解决方案手册,是国内知名大数据公司神策数据在该领域多年实践经验的总结. 本书详细阐述了 Android 全埋点的 8 种解决方案, ...

  7. 《iOS 全埋点技术白皮书》重磅推出

    数据埋点技术在互联网尤其是移动端上使用非常普遍,全埋点采用"全部采集,按需选取"的形式,对页面中所有交互元素的用户行为进行采集,通过界面配置来决定哪些数据需要进行分析,也被誉为&q ...

  8. 外地车 摄像头 android,Android端车牌识别可以用来实现摄像头扫描识别车牌?

    原标题:Android端车牌识别可以用来实现摄像头扫描识别车牌? 随着汽车的需求暴增,车辆管理成为了城市管理的重中之重.Android端车牌识别技术已被广泛应用于城市智能交通.智慧小区的系统中,以往是 ...

  9. 最近无埋点技术很是流行,抽空研究了下诸葛IO,talkingData以及百分点这些业内知名公司的无埋点SDK,抽取其中重要的信息供大家参考:

    1.首先什么是无埋点呢,其实所谓无埋点就是开发者无需再对追踪点进行埋码,而是脱离代码,只需面对应用界面圈圈点点即可追加随时生效的事件数据点. 无埋点的好处 其实无埋点并不是完全不用写代码,而是尽可能的 ...

  10. Android无埋点的技术选型之路

    数极客是国内新一代用户行为分析平台,支持无埋点采集,前端代码埋点采集,后端代码埋点采集等多种混合数据采集方式,支持30多种数据可视化效果,是增长***必的备大数据分析工具,支持APP分析数据网站分析及 ...

最新文章

  1. Pycharm+Django搭建第一个Python Web程序
  2. 临时配置网络(ip,网关,dns)+永久配置
  3. linux/unix lsof用法
  4. Nginx的http块其他的配置指令说明
  5. Oracle Golden Gate 系列十四 -- 监控 GG 状态 说明
  6. .NET FM的未来计划
  7. 怎样创建两个菜单JAVA_java – 如何创建一个菜单的JButton?
  8. 字节序转换 oracle,Oracle10g同字节序跨平台迁移
  9. nginx备忘录,错误a duplicate default server for 0.0.0.0:80
  10. 信息学奥赛一本通(1010:计算分数的浮点数值)
  11. Kubernetes-标签和注解(二十二)
  12. mysql workbench查询快捷_mysql workbench快捷键
  13. 企业数字化劳动力-Srise RPA产品
  14. html css 自动滚动代码,使用CSS自动滚动
  15. Python学习入门基础教程(learning Python)--5.3 Python写文件基础
  16. Julia: 用Julia操作Redis数据库及一些用法
  17. ROS做端口映射DDNS的N个做法详细教程
  18. 【DM642】ICELL Interface—Cells as Algorithm Containers
  19. 流媒体后视镜方案关键技术--电子防眩目
  20. python通过QQ邮箱或163邮箱发送邮件(可带附件)

热门文章

  1. Glide 显示圆形头像
  2. IT“茫一代”转型记:创业维艰 苦乐皆有
  3. 使用commons-lang3实现Unicode码转中文
  4. 递归例题讲解 一本通1215:迷宫 答案解析
  5. 0809 电子科学与技术一级学科简介
  6. Python用 matplotlib 工具包来绘制世界地图
  7. Referrer还是Referer? 一个迷人的错误
  8. VMware虚拟机如何联网详解
  9. 教你App如何上架应用宝----腾讯开放开发平台
  10. Kafka序列化器,分区器,拦截器,消息累加器