Android换肤笔记
今日科技快讯
近日,江苏南京的颜女士先后买了两部iPhone X手机,却均能被一位非亲属关系的女同事面部解锁。她担心手机的安全问题。苹果店工作人员表示这是个案,如果对产品不满意可选择退货。
作者简介
本篇文章来自 aprz512 的投稿。主要介绍了Android换肤功能实现的原理,以及提供了一个换肤的框架,希望对大家有所帮助!
aprz512 的博客地址:
http://aprz512.github.io/
效果预览
文章开始,我们先来预览一下换肤功能所能实现的效果吧。如下图所示:
换肤原理
本文所讲述的换肤是通过干预 xml 的解析实现的。在解析xml时,我们可以收集需要换肤的 view,并记录下 view 的一些换肤信息,等要需要换肤的时候,从皮肤资源包中加载皮肤,设置到记录的 view 上。
Activity加载xml文件
新建一个android项目,在MainActivity中覆写onCreate()方法,代码如下:
@Override
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_setting); ... }
为了能够将xml显示出来,我们必须调用 setContentView() 方法。
该方法源码如下:
public void setContentView(@LayoutRes int layoutResID) { getWindow().setContentView(layoutResID); initWindowDecorActionBar(); }
调用了 window 的 setContentView()。这里 window 的实现是 PhoneWindow。
@Override
public void setContentView(int layoutResID) { // Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window // decor, when theme attributes and the like are crystalized. Do not check the feature // before this happens. if (mContentParent == null) { installDecor(); } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) { mContentParent.removeAllViews(); } if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) { final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID, getContext()); transitionTo(newScene); } else { mLayoutInflater.inflate(layoutResID, mContentParent); } mContentParent.requestApplyInsets(); final Callback cb = getCallback(); if (cb != null && !isDestroyed()) { cb.onContentChanged(); } }
installDecor() 是加载我们在activity上设置的theme信息。
重点是 mLayoutInflater.inflate(layoutResId, mContentParent);
调用了 LayoutInflater 的 inflate 方法:
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) { return inflate(resource, root, root != null); }
继续调用同名方法:
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) { final Resources res = getContext().getResources(); if (DEBUG) { Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" (" + Integer.toHexString(resource) + ")"); } final XmlResourceParser parser = res.getLayout(resource); try { return inflate(parser, root, attachToRoot); } finally { parser.close(); } }
这里就开始解析xml了。继续跟踪 inflate 方法:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) { synchronized (mConstructorArgs) { ...... try { ...... if (TAG_MERGE.equals(name)) { if (root == null || !attachToRoot) { throw new InflateException("<merge /> can be used only with a valid " + "ViewGroup root and attachToRoot=true"); } rInflate(parser, root, inflaterContext, attrs, false); } else { // Temp is the root view that was found in the xml final View temp = createViewFromTag(root, name, inflaterContext, attrs); ViewGroup.LayoutParams params = null; if (root != null) { if (DEBUG) { System.out.println("Creating params from root: " + root); } // Create layout params that match root, if supplied params = root.generateLayoutParams(attrs); if (!attachToRoot) { // Set the layout params for temp if we are not // attaching. (If we are, we use addView, below) temp.setLayoutParams(params); } } if (DEBUG) { System.out.println("-----> start inflating children"); } // Inflate all children under temp against its context. rInflateChildren(parser, temp, attrs, true); if (DEBUG) { System.out.println("-----> done inflating children"); } // We are supposed to attach all the views we found (int temp) // to root. Do that now. if (root != null && attachToRoot) { root.addView(temp, params); } // Decide whether to return the root that was passed in or the // top view found in xml. if (root == null || !attachToRoot) { result = temp; } } } catch (XmlPullParserException e) { InflateException ex = new InflateException(e.getMessage()); ex.initCause(e); throw ex; } catch (Exception e) { InflateException ex = new InflateException( parser.getPositionDescription() + ": " + e.getMessage()); ex.initCause(e); throw ex; } finally { // Don"t retain static reference on context. mConstructorArgs[0] = lastContext; mConstructorArgs[1] = null; } Trace.traceEnd(Trace.TRACE_TAG_VIEW); return result; } }
当我们编写一个xml给activity使用的时候,根节点肯定不会是 merge ,所以这里走 else 里面的代码。首先,调用 createViewFromTag 创建根节点,然后调用 rInflateChildren 创建子节点。最后根据参数,判断是否将根节点添加到 root 上。我们先看 createViewFromTag 的代码:
private View createViewFromTag(View parent, String name, Context context, AttributeSet attrs) { return createViewFromTag(parent, name, context, attrs, false); }
调用同名方法:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) { if (name.equals("view")) { name = attrs.getAttributeValue(null, "class"); } // Apply a theme wrapper, if allowed and one is specified. if (!ignoreThemeAttr) { final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME); final int themeResId = ta.getResourceId(0, 0); if (themeResId != 0) { context = new ContextThemeWrapper(context, themeResId); } ta.recycle(); } if (name.equals(TAG_1995)) { // Let"s party like it"s 1995! return new BlinkLayout(context, attrs); } try { View view; if (mFactory2 != null) { view = mFactory2.onCreateView(parent, name, context, attrs); } else if (mFactory != null) { view = mFactory.onCreateView(name, context, attrs); } else { view = null; } if (view == null && mPrivateFactory != null) { view = mPrivateFactory.onCreateView(parent, name, context, attrs); } if (view == null) { final Object lastContext = mConstructorArgs[0]; mConstructorArgs[0] = context; try { if (-1 == name.indexOf(".")) { view = onCreateView(parent, name, attrs); } else { view = createView(name, null, attrs); } } finally { mConstructorArgs[0] = lastContext; } } return view; } catch (InflateException e) { throw e; } catch (ClassNotFoundException e) { final InflateException ie = new InflateException(attrs.getPositionDescription() + ": Error inflating class " + name); ie.initCause(e); throw ie; } catch (Exception e) { final InflateException ie = new InflateException(attrs.getPositionDescription() + ": Error inflating class " + name); ie.initCause(e); throw ie; } }
重点看 try 里面的这段代码:
View view; if (mFactory2 != null) { view = mFactory2.onCreateView(parent, name, context, attrs); } else if (mFactory != null) { view = mFactory.onCreateView(name, context, attrs); } else { view = null; } if (view == null && mPrivateFactory != null) { view = mPrivateFactory.onCreateView(parent, name, context, attrs); } if (view == null) { final Object lastContext = mConstructorArgs[0]; mConstructorArgs[0] = context; try { if (-1 == name.indexOf(".")) { view = onCreateView(parent, name, attrs); } else { view = createView(name, null, attrs); } } finally { mConstructorArgs[0] = lastContext; } }
如果 mFactory2 mFactory 其中一个有值,会是调用其 onCreateView 方法。
mFactor2 优先级高于 mFactory。如果都没有值,使用 mPrivateFactory 的 onCreateView 方法。如果 mPrivateFactory 也为空,则使用自己的 onCreateView 或者 createView 方法。
PS:LayoutInflater 的 onCreateView 方法也会调到 createView 方法。
这3个Factory赋值都是在构造函数,以及 set 方法中。
再回到 PhoneWindow 的 setContentView 中:
@Override
public void setContentView(int layoutResID) { ...... if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) { ...... } else { mLayoutInflater.inflate(layoutResID, mContentParent); } ...... }
查看 mLayoutInflater 是如何赋值的:
public PhoneWindow(Context context) { super(context); mLayoutInflater = LayoutInflater.from(context); }
查看 from 方法做了什么:
public static LayoutInflater from(Context context) { LayoutInflater LayoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); if (LayoutInflater == null) { throw new AssertionError("LayoutInflater not found."); } return LayoutInflater; }
Context 的实现是 ContextImpl:
@Override
public Object getSystemService(String name) { return SystemServiceRegistry.getSystemService(this, name); }
往下调用:
public static Object getSystemService(ContextImpl ctx, String name) { ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name); return fetcher != null ? fetcher.getService(ctx) : null; }
调用 ServiceFetcher 的 getService:
static abstract interface ServiceFetcher<T> { T getService(ContextImpl ctx); }
是一个接口,有抽象类(CachedServiceFetcher)实现了这个接口:
static abstract class CachedServiceFetcher<T> implements ServiceFetcher<T> { private final int mCacheIndex; public CachedServiceFetcher() { mCacheIndex = sServiceCacheSize++; } @Override @SuppressWarnings("unchecked") public final T getService(ContextImpl ctx) { final Object[] cache = ctx.mServiceCache; synchronized (cache) { // Fetch or create the service. Object service = cache[mCacheIndex]; if (service == null) { service = createService(ctx); cache[mCacheIndex] = service; } return (T)service; } } public abstract T createService(ContextImpl ctx); }
先从cache里面去找,找不到再去创建,创建的方法是抽象的。这里先放着,看这个类的静态代码块:
static { ....... registerService(Context.LAYOUT_INFLATER_SERVICE, LayoutInflater.class, new CachedServiceFetcher<LayoutInflater>() { @Override public LayoutInflater createService(ContextImpl ctx) { return new PhoneLayoutInflater(ctx.getOuterContext()); }}); ....... }
从这里可以看到,使用匿名内部类实现了上面的抽象方法,即 PhoneLayoutInflater 就是我们调用 LayoutInflater.from(context) 得到的。
看其构造函数:
public PhoneLayoutInflater(Context context) { super(context); }
PhoneLayoutInflater 继承了 LayoutInflater :
protected LayoutInflater(Context context) { mContext = context; }
即 PhoneLayoutInflater 中并没有给 mFractory mFractory2 mPrivateFactory 复制。所及加载xml的时候,使用的是内部的 createView 方法。
到这里就不往下分析了,换肤的一个难点就已经被解决了—即如何干预 xml 的解析。很显然,通过分析源码,只要给 LayoutInflater 设置一个 Factory 就好了,只不过我们需要自己实现 Factory 接口。
如何实现一个 Factory 这里先放置一下,activity 的 xml 加载搞定了,那么 fragment 的 xml 加载又是什么样的呢?
首先编写一个 fragment ,如下:
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.fragment_article_list, container, false); initView(v); return v; }
可以看出加载xml是由inflate完成的。那这个参数是谁传递的呢?
想想我们平时使用fragment的方式:
private void initFragment() { FragmentManager fm = getSupportFragmentManager(); Fragment fragment = fm.findFragmentById(R.id.fragment_container); if (fragment == null) { fragment = ArticleListFragment.newInstance(); fm.beginTransaction() .add(R.id.fragment_container, fragment) .commit(); } }
看看 FragmentManager 做了什么,FragmentManager 的实现是FragmentManagerImpl(这里我就只给出最终的代码了,懒得分析了):
void moveToState(Fragment f, int newState, int transit, int transitionStyle, boolean keepActive) { ...... switch (f.mState) { case Fragment.INITIALIZING: ...... f.onAttach(mHost.getContext()); ...... if (!f.mRetaining) { f.performCreate(f.mSavedFragmentState); } f.mRetaining = false; if (f.mFromLayout) { // For fragments that are part of the content view // layout, we need to instantiate the view immediately // and the inflater will take care of adding it. f.mView = f.performCreateView(f.getLayoutInflater( f.mSavedFragmentState), null, f.mSavedFragmentState); if (f.mView != null) { ...... f.onViewCreated(f.mView, f.mSavedFragmentState); } else { f.mInnerView = null; } }
重点是
f.mView = f.performCreateView(f.getLayoutInflater(f.mSavedFragmentState), null, f.mSavedFragmentState);
这句代码,inflater 参数就是 f.getLayoutInflater 这个方法得来的。
perfromCreateView 调用了 onCreateView。这里需要注意的就是,getLayoutInflater方法是不可见的,但是我们依然可以覆盖。
所以如果我们想要实现换肤,那么 BaseActivity 和 BaseFragment 的代码如下:
public class BaseActivity extends Activity { private SkinInflaterFactory mSkinInflaterFactory; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mSkinInflaterFactory = new SkinInflaterFactory(); getLayoutInflater().setFactory(mSkinInflaterFactory); } @Override protected void onDestroy() { super.onDestroy(); mSkinInflaterFactory.clean(); } } public class BaseFragment extends Fragment { public LayoutInflater getLayoutInflater(Bundle savedInstanceState) { return getActivity().getLayoutInflater(); } }实现Factory接口public class SkinInflaterFactory implements Factory { /** * 用一个集合将需要换肤的view,以及view的信息存起来 */ private List<SkinItem> mSkinItems = new ArrayList<>(); @Override public View onCreateView(String name, Context context, AttributeSet attrs) { // 我们自定义了一个属性,每个view都可以使用 // 在xml里使用如下 skin:enable="true" 表示换肤 // 如果不换肤,直接返回空,这里返回null,会走原来的xml解析逻辑 // 源码里面分析过了 boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false); if (!isSkinEnable) { return null; } View view = createView(context, name, attrs); if (view == null) { return null; } // 解析要换肤view的信息 parseSkinAttr(context, attrs, view); return view; } // 这个方法就是创建view,还是走layoutInflater的createView逻辑 private View createView(Context context, String name, AttributeSet attrs) { View view = null; try { if (-1 == name.indexOf(".")) { if ("View".equals(name)) { view = LayoutInflater.from(context).createView(name, "android.view.", attrs); } if (view == null) { view = LayoutInflater.from(context).createView(name, "android.widget.", attrs); } if (view == null) { view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs); } } else { view = LayoutInflater.from(context).createView(name, null, attrs); } L.i("about to create " + name); } catch (Exception e) { L.e("error while create 【" + name + "】 : " + e.getMessage()); view = null; } return view; } // 代码中的注释很详细了 // attrName 表示要换肤的属性 textColor 等 // id 表示属性对应的资源id // entryName 表示属性对应的资源名字 // entryType 表示属性对应的资源类型 color 还是 drawable 等 private void parseSkinAttr(Context context, AttributeSet attrs, View view) { List<SkinAttr> viewAttrs = new ArrayList<>(); for (int i = 0; i < attrs.getAttributeCount(); i++) { /** * get 出来的是这样的东西: * attrName = divider, attrValue = http://www.gunmi.cn/v/@2131099656 * attrName = textColor, attrValue = @2131099660 * attrName = background, attrValue = @2131099658 */ String attrName = attrs.getAttributeName(i); String attrValue = attrs.getAttributeValue(i); if (!AttrFactory.isSupportedAttr(attrName)) { continue; } // xml 编译之后 attrValue 值是 @ + 数值的形式 if (attrValue.startsWith("@")) { try { int id = Integer.parseInt(attrValue.substring(1)); /** * get 出来的是这样的: * entryName typeName * news_item_text_color_selector color * news_item_selector drawable */ String entryName = context.getResources().getResourceEntryName(id); String typeName = context.getResources().getResourceTypeName(id); SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName); if (mSkinAttr != null) { viewAttrs.add(mSkinAttr); } } catch (NumberFormatException | NotFoundException e) { e.printStackTrace(); } } } if (!ListUtils.isEmpty(viewAttrs)) { SkinItem skinItem = new SkinItem(); skinItem.view = view; skinItem.attrs = viewAttrs; mSkinItems.add(skinItem); if (SkinManager.getInstance().isExternalSkin()) { skinItem.apply(); } } } public void applySkin() { if (ListUtils.isEmpty(mSkinItems)) { return; } for (SkinItem si : mSkinItems) { if (si.view == null) { continue; } si.apply(); } } public void addSkinView(SkinItem item) { mSkinItems.add(item); } public void clean() { if (ListUtils.isEmpty(mSkinItems)) { return; } for (SkinItem si : mSkinItems) { if (si.view == null) { continue; } si.clean(); } } }
收集了所有的换肤信息,我们就要讨论第二个问题了。
加载皮肤中的资源
想想我们在开发时,是如何使用资源的:
getResource().getString(id); getResource().getColor(id); getResource().getDrawable(id);
看看getString源码:
public String getString(@StringRes int id) throws NotFoundException { final CharSequence res = getText(id); if (res != null) { return res.toString(); } throw new NotFoundException("String resource ID #0x" + Integer.toHexString(id)); }
继续 getText(id):
public CharSequence getText(@StringRes int id) throws NotFoundException { CharSequence res = mAssets.getResourceText(id); if (res != null) { return res; } throw new NotFoundException("String resource ID #0x" + Integer.toHexString(id)); }
可以看到资源实际上是由 mAssets 加载的。查看 getColor 和 getDrawable 源码同样如此。
从这里就要开始往上追溯了,因为我们需要知道 AssetManager 是如何创建的,它是怎么对应的app的资源。
再次回到 ContextImpl,查看 getResource 方法:
@Override
public Resources getResources() { return mResources; }
直接返回成员变量,看看在哪里赋值的:
private ContextImpl(ContextImpl container, ActivityThread mainThread, LoadedApk packageInfo, IBinder activityToken, UserHandle user, boolean restricted, Display display, Configuration overrideConfiguration, int createDisplayWithId) { ...... mResourcesManager = ResourcesManager.getInstance(); ...... Resources resources = packageInfo.getResources(mainThread); if (resources != null) { if (displayId != Display.DEFAULT_DISPLAY || overrideConfiguration != null || (compatInfo != null && compatInfo.applicationScale != resources.getCompatibilityInfo().applicationScale)) { resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(), packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(), packageInfo.getApplicationInfo().sharedLibraryFiles, displayId, overrideConfiguration, compatInfo); } } mResources = resources; ...... }
Resource 由 ResourceManager 的 getTopLevelResources 方法创建:
Resources getTopLevelResources(String resDir, String[] splitResDirs, String[] overlayDirs, String[] libDirs, int displayId, Configuration overrideConfiguration, CompatibilityInfo compatInfo) { ...... Resources r; synchronized (this) { // Resources is app scale dependent. if (DEBUG) Slog.w(TAG, "getTopLevelResources: " + resDir + " / " + scale); WeakReference<Resources> wr = mActiveResources.get(key); r = wr != null ? wr.get() : null; //if (r != null) Log.i(TAG, "isUpToDate " + resDir + ": " + r.getAssets().isUpToDate()); if (r != null && r.getAssets().isUpToDate()) { if (DEBUG) Slog.w(TAG, "Returning cached resources " + r + " " + resDir + ": appScale=" + r.getCompatibilityInfo().applicationScale + " key=" + key + " overrideConfig=" + overrideConfiguration); return r; } } AssetManager assets = new AssetManager(); // resDir can be null if the "android" package is creating a new Resources object. // This is fine, since each AssetManager automatically loads the "android" package // already. if (resDir != null) { if (assets.addAssetPath(resDir) == 0) { return null; } } if (splitResDirs != null) { for (String splitResDir : splitResDirs) { if (assets.addAssetPath(splitResDir) == 0) { return null; } } } if (overlayDirs != null) { for (String idmapPath : overlayDirs) { assets.addOverlayPath(idmapPath); } } if (libDirs != null) { for (String libDir : libDirs) { if (libDir.endsWith(".apk")) { // Avoid opening files we know do not have resources, // like code-only .jar files. if (assets.addAssetPath(libDir) == 0) { Log.w(TAG, "Asset path "" + libDir + "" does not exist or contains no resources."); } } } } ...... r = new Resources(assets, dm, config, compatInfo); if (DEBUG) Slog.i(TAG, "Created app resources " + resDir + " " + r + ": " + r.getConfiguration() + " appScale=" + r.getCompatibilityInfo().applicationScale); synchronized (this) { WeakReference<Resources> wr = mActiveResources.get(key); Resources existing = wr != null ? wr.get() : null; if (existing != null && existing.getAssets().isUpToDate()) { // Someone else already created the resources while we were // unlocked; go ahead and use theirs. r.getAssets().close(); return existing; } // XXX need to remove entries when weak references go away mActiveResources.put(key, new WeakReference<>(r)); if (DEBUG) Slog.v(TAG, "mActiveResources.size()=" + mActiveResources.size()); return r; } }
直接new AssetManager 并且调用 addAssetPath 方法将资源路传进去。如果有 lib (.apk),也添加进去。看到这里就很清楚了,我们要去加载的皮肤包可以是一个 apk 文件。所以读取皮肤包资源的思路就清晰了,我们可以新建一个工程,里面只放皮肤资源,最后打包成apk,我们的app拿到这个apk就可以加载出里面的资源。
创建资源包的 Resource
我们自己的apk只能记载自己应用下的资源目录,要想去加载别的资源目录,我们就可以创建一个 Resource 对象,替换里面的 AssetManager,让 AssetManager 里面的 path 对应为资源包(.apk)的路径。
具体代码如下:
public void load(String skinPackagePath, final ILoaderListener callback) { new AsyncTask<String, Void, Resources>() { protected void onPreExecute() { if (callback != null) { callback.onStart(); } } @Override protected Resources doInBackground(String... params) { try { if (params.length == 1) { String skinPkgPath = params[0]; File file = new File(skinPkgPath); if (!file.exists()) { return null; } PackageManager mPm = context.getPackageManager(); PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES); skinPackageName = mInfo.packageName; // 创建资源包的 assetManager AssetManager assetManager = AssetManager.class.newInstance(); // 利用反射添加资源路径 Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class); addAssetPath.invoke(assetManager, skinPkgPath); Resources superRes = context.getResources(); Resources skinResource = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration()); SkinConfig.saveSkinPath(context, skinPkgPath); skinPath = skinPkgPath; isDefaultSkin = false; return skinResource; } return null; } catch (Exception e) { e.printStackTrace(); return null; } } protected void onPostExecute(Resources result) { mResources = result; if (mResources != null) { if (callback != null) callback.onSuccess(); notifySkinUpdate(); } else { isDefaultSkin = true; if (callback != null) callback.onFailed(); } } }.execute(skinPackagePath); }
有了 Resource 之后,就可以加载皮肤资源了,下面是一段加载不同皮肤下的drawable代码。
public Drawable getDrawable(int resId) { Drawable originDrawable = context.getResources().getDrawable(resId); if (mResources == null || isDefaultSkin) { return originDrawable; } // 拿到id对应的资源名字 String resName = context.getResources().getResourceEntryName(resId); // 根据资源名字找到皮肤包中的id int trueResId = mResources.getIdentifier(resName, "drawable", skinPackageName); Drawable trueDrawable = null; try { if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { trueDrawable = mResources.getDrawable(trueResId); } else { trueDrawable = mResources.getDrawable(trueResId, null); } } catch (NotFoundException e) { e.printStackTrace(); trueDrawable = originDrawable; } return trueDrawable; }
这样换肤就实现了。
github地址如下:
http://github.com/aprz512/Android-Skin-Loader
欢迎长按下图 -> 识别图中二维码
或者 扫一扫 关注我的公众号
- 网友相约见面 次日顺走笔记本电脑卖了
- Android 8.1 曝严重 Bug,搜狗推出「唇语识别」技术,迪士尼收购
- 逐渐取代QQ?Android版《微信》内测:可一对一发文件
- 免费赠书 | 评论区留言就送《Android进阶之光》
- 《非营利组织的管理》读书笔记
- 【FICC工作笔记】2017年12月12日星期二
- 它可能是市面上最牛的裸眼3D笔记本,背后的3D技术竟是得益于这家
- 12月12日的投资笔记与思考
- 权威中医笔记公开:脱发、斑秃,中医3招轻松搞定!为需要的人存
- Android 应用安装包“瘦身”大作战