Android 屏幕适配剖析

前言

众所周知,Android受权的厂商不可胜数,生产出的机型也数不胜数,致使尺寸碎片化很严重。固然,都9102年了,你们逐渐获得了最优解,国内主流机型基本上都在720、1080、1440徘徊,最多高度上各有所长,可是仍是保留着很多其余分辨率的手机,先来看一组数据(来源:友盟)——html

如图所证上述结论的正确性,可是能够看到,每一年都有比例不小的其余尺寸的手机占据着市场份额,更况且那些还在服役的古董机器。我相信,这部分用户群是不可能被产品经理所割舍的。git

为了解决这个问题,咱们固然能够——github

  1. 善于使用RelativeLayout、Linearlayout、ConstraintLayout;
  2. 合理使用wrap_content、match_content;
  3. 使用minHeight、minWidth、lines、ellipsize等等属性;
  4. 使用dp、sp单位;
  5. 以某个页面为单位针对不一样的手机使用不一样的布局、图片、dimen;

可是我想说,以上种种,只是一个Android开发应该具有的基本素质。也许有人会问,这些还不够吗?并且dp、sp不已是官方适配过了的单位吗?下面咱们就来逐步剖析。web

为何官方须要使用设备独立像素适配

设备独立像素(dp、sp),又叫逻辑像素,是一种用缩放因子(scale)计算出来的、和像素有必定的换算比例的、不受设备分辨率和密度(ppi)制约的尺寸单位bash

那么什么是分辨率,什么是ppi,什么是dpi。微信

分辨率是指在手机屏幕中横竖都有多少个像素点,所谓的1080x1920便是指,屏幕的高有1920个像素点,宽有1080个像素点。当咱们继续查看手机参数的时候,会看到下一个指标,叫作ppi(Pixels Per Inch),表示每英寸所包含的像素点个数,ppi越大,屏幕越细腻,可是超过了肉眼的分辨率是没有多大意义的,以荣耀10为例,它的分辨率是1080x2280,那么对角线所具备的像素点个数为2522.86,而主屏尺寸是5.84英寸,那么咱们能够得出每英寸所包含的像素点个数为431.997≈432,即ppi=432。那么dpi又是什么?dpi(Dots per Inch),字面意思是每英寸包含的点数,可是实际上它如今更多的用于表示显示策略中的一个参数,在Android中,它是能够在系统中设置的、是可变的、是用于计算缩放因子的,也许在不少文章中咱们均可以看到ppi就是dpi这样的言论,可是其实它们已经和最初的释义有所差别,具体参照WHAT IS DPI,我的认为这篇文章是讲述比较全面、靠谱、符合事实的。app

而独立像素,为何不受分辨率和密度制约?框架

咱们首先明白,当咱们假定的认为像素点都是趋于正方形时,密度只能影响视觉呈现的物理大小和精细程度,屏幕上高宽都为x个像素所组成的正方形,在相同分辨率不一样密度的手机中,他们只是视觉大小不同,可是占据屏幕的比例是一致的。ide

那么咱们只须要分析为何独立像素不受分辨率制约。布局

咱们知道,每一个手机出厂时都写好了固定的dpi在手机的系统文件中,而dpi是造成独立像素的一个重要参数,咱们能够根据dpi计算出dp和px的换算比例,也就是缩放因子(scale),从官方的文档,咱们能够得出一个公式

1dp = 1px * scale = 1px * dpi / 160
复制代码

也就是说,只要按照相同的规则控制dpi的数值,咱们在宽度的纬度上,能够作到将全部分辨率都换算成一个数值的设备独立像素。

打个比方,如今大部分720x1280的手机的dpi都等于320,根据公式可得,宽度为360dp,而1080x1920的手机大部分的dpi都等于480,一样根据公式所得宽度为360dp。

那么相同的设备独立像素能够作什么呢?只要咱们设置成宽度为180dp,那么它永远是占据屏幕宽度一半的比例。

而如果咱们直接使用像素做为控件的单位,那么是没法保证它在不一样分辨率的手机是占据相同的比例。

一样举个例子,在720x1280的手机上咱们设置宽度为360px,它将占据一半的屏幕,而在1080x1920的手机上只能占据三分之一的屏幕。

这就是为何官方推荐咱们使用设备独立像素做为尺寸的单位。那么问题来了,既然设备独立像素这么优秀,那么咱们——

为何要二次适配

上节举例中说到,大部分720x1280、1080x1920的手机宽度都是360dp,而大部分480x800的手机(dpi=240)宽度是320dp,那么当设计稿是360dp的时候,会发生什么。

举个例子,以下图所示,两台设备分辨率一致,可是dpi不一致,前者是480dpi,后者是540dpi(ps:不要问有没有这种机器,nove4e就是这样的),而设计稿是以360dp为基准,热度排名和贡献排名的宽度比例是3:4,则咱们能够看到其在320dp下表现比较差,即便咱们再如何布局,如何使用属性,它永远是不完美的,由于它的逻辑宽度永远都比设计稿少40dp。

而除了320dp、360dp,单国内手机的逻辑宽度就还有345.6dp、375.6dp、392.7dp、411.4dp、423.5dp等等。固然,理论上来讲更多的逻辑宽度应该显示更多的内容,然而现实的状况每每不容许,这意味着——

  1. 须要设计多套图;
  2. 开发工做繁重、维护困难;
  3. 增大包体积,毕竟不像iOS有App Slicing;

总之,就是人力成本过高。可是经过二次适配,咱们能够作到一套设计图适配“全部”设备,一套布局“全家”适用。也许这不是最好的方案,可是综合来看这是最合适的方案,是最具性价比的方案。那么咱们要——

怎么作二次适配

作二次适配的方法有多种,大致能够分为穷举Hook

注:由于当下大部分app的应用场景只在于竖屏,即便有横屏的界面也只须要保持高度不变,宽度自适应。退一步讲,真的有个别页面的高度也须要自适应时,能够具体场景具体分析,即便不作二次适配,也是ok的。所以如下的适配方法只从宽度的纬度来说诉。

穷举宽高限定符

咱们都知道,宽高限定符的匹配规则是,双边都小于屏幕分辨率的最接近的值。根据这个规则,咱们尽量的枚举出全部的分辨率(虽然分辨率有不少,可是咱们只须要按照宽度来枚举便可,高度设置成略大于宽度)。而根据testinwetest云真机的分辨率,咱们能够得出文件结构以下:

+-- res
|   +-- values
|   +-- values-330x320
|   +-- values-490x480
|   +-- values-550x540
|   +-- values-650x640
|   +-- values-730x720
|   +-- values-780x768
|   +-- values-810x800
|   +-- values-1100x1080
|   +-- values-1160x1152
|   +-- values-1210x1200
|   +-- values-1450x1440
|   +-- values-2170x2160
复制代码

而后以1080px为基准,计算出1px在其余分辨率下的等比值 (注:默认values=values-1100x1080),假设目标分辨率的宽为W,则公式为:

px' = W/1080
复制代码

举个例子,720px的分辨率的dimens值为:

<resources>
    <dimen name="x1">0.66px</dimen>
    <dimen name="x2">1.33px</dimen>
    <dimen name="x3">2.0px</dimen>
    <dimen name="x4">2.66px</dimen>
    <dimen name="x5">3.33px</dimen>
    <dimen name="x6">4.0px</dimen>
    <dimen name="x7">4.66px</dimen>
    <dimen name="x8">5.33px</dimen>
    <dimen name="x9">6.0px</dimen>
    <dimen name="x10">6.66px</dimen>
    .
    .
    .
    <dimen name="x1080">720px</dimen>
</resources>
复制代码

配置好后,咱们从以上分辨率中选择9种采样,看看实际运行效果如何:

由上图可见,运行结果是很是符合咱们预期值的,占一半屏幕的仍是占一半,热度排名和贡献排名的间距也基本差很少,惟一比较明显的是每行的文字字数±1,这是因为换算以后的像素有小数点形成的,可是这是能够接受的。

而后咱们再来分析一下极端状况,首先由于咱们是穷举分辨率,因此此处不须要考虑dpi,又由于咱们穷举的分辨率的宽是320-2160,因此咱们能够从这个角度考虑边界值:

  1. 宽低于320px,匹配默认values,此种状况能够认为不存在;
  2. 宽等于某一个枚举值(假设为720px),高度刚好小于730px,匹配大一级的values,可是由于咱们设置的高度只是略大于宽,能够认为此种状况不存在;
  3. 宽大于某一个枚举值,可是小于下一级的宽度(假设为1600px),匹配values-1450x1440;
  4. 宽小于某一个枚举值,可是大于上一级的宽度(假设为1300px),匹配values-1210x1200,等同于第3点;
  5. 宽大于2160px,匹配values-2170x2160,等同于第3点;

咱们再看看三、四、5的运行效果如何:

从结果能够看出,咱们出现的极端状况都是比预期值要宽,这是由于咱们分辨率限定符是向下匹配的。

综上所述,咱们得出结论:

  1. 在已知分辨率的设备中,此方法基本能够完美适配机型;
  2. 全部地方都建议统一使用px',包括字体、自定义控件等,不然就会不兼容;
  3. 由于字体也须要使用px',因此app字体大小不会受到系统设置——字体显示大小的影响;
  4. 由于使用了px',代码中动态设置大小间距等等要额外注意单位;
  5. 由于非1080px是换算比例,必然存在小数点,所以会存在一丢丢偏差;
  6. 枚举分辨率太多,致使dimens文件过多,包体积会增大一点,若是1080个px所有作映射的话,以示例中的枚举值大概要多0.5MB左右;
  7. 由于存在场外分辨率,即便使用了此适配,依然不能够盲目的用绝对值,仍是要配合其余控件属性一并使用;
  8. 侵入性比较高,依赖技术人员的素养;

穷举最小宽度限定符

最小宽度限定符,是指在逻辑宽度上限定使用小于而且最接近于屏幕宽度的资源。而逻辑宽度(W')能够从分辨率(W)和dpi得知:

W' = W / ( dpi / 160 )
复制代码

咱们能够和穷举分辨率限定符同样,穷举出全部可能的逻辑宽度。为了分析,咱们暂定文件结构以下:

+-- res
|   +-- values
|   +-- values-sw320dp
|   +-- values-sw360dp
|   +-- values-sw411dp
复制代码

而后以360dp为基准,计算出1dp在其余逻辑宽度下的等比值 (注:默认values=values-sw360dp),假设目标逻辑宽度为W,则公式为:

dp' = W/360
复制代码

一样的举个例子,320dp的逻辑宽度的dimens值为:

<resources>
    <dimen name="dp_1">0.89dp</dimen>
    <dimen name="dp_2">1.78dp</dimen>
    <dimen name="dp_3">2.67dp</dimen>
    <dimen name="dp_4">3.56dp</dimen>
    <dimen name="dp_5">4.44dp</dimen>
    <dimen name="dp_6">5.33dp</dimen>
    <dimen name="dp_7">6.22dp</dimen>
    <dimen name="dp_8">7.11dp</dimen>
    <dimen name="dp_9">8.00dp</dimen>
    <dimen name="dp_10">8.89dp</dimen>
    .
    .
    .
    <dimen name="dp_360">320dp</dimen>
</resources>
复制代码

咱们来看看实际的运行效果:

显而易见,又是符合咱们预期的,可是不可避免的是逻辑宽度依旧存在刺头(缘由见文章开头——为何要二次适配),好比384dp(Nexus 4)、392dp(XiaoMi MIX2),因此咱们再次来看看极端状况:

  1. 逻辑宽度小于320dp,虽然没有数据支撑,可是咱们假定的认为这已是最小的宽度了,或者说,总有一个最小值(后续等统计出来,补上相关数据);
  2. 逻辑宽度位于两个枚举值之间,好比384dp;
  3. 逻辑宽度大于411dp;

好了,由于最小宽度限定符依旧是向下匹配的,从而又回到了和上一节如出一辙的状况——极端状况比预期值要宽,因此此处咱们再也不重复贴图。

那么咱们总结一下此节:

  1. 在已经枚举的逻辑宽度中,基本能够完美匹配设备;
  2. xml、代码、包括自定义控件中都须要使用dp'的引用来保持一致;
  3. 须要再配置一套TextSize,至因而用dp仍是sp,仁者见仁智者见智(微信没有用sp);
  4. 枚举值映射的dp'值比宽高限定符少;
  5. 相比宽高限定符兼容性略高,即便有些地方直接写成了dp,也是能够的;
  6. 一样由于是换算比例,必然存在小数点,最后应用成px时,也可能存在一丢丢的偏差;
  7. 包体积和dimens的枚举数量成正比;
  8. 一样存在场外dpi,不能够盲目的用绝对值;
  9. 侵入性比较高,依赖技术人员的素养;

上面说了如何穷举来进行适配,可是如何穷举的既完整又简洁是一个难点,那么可不可能有一个测量的终点,全部的间距、大小、尺寸都会经过这里,咱们在这个终点进行自动化适配就行了?固然是有的。

选择onMeasure进行Hook

咱们知道,view是须要先measure而后layout而后才draw的,那么切入点就来了——onMeasure。

典型的例子是AndroidAutoLayout,它的使用方法详见ReadMe,这里再也不赘述。其核心思想是即是经过重写其onMeasure,在调用super.onMeasure(widthMeasureSpec, heightMeasureSpec)以前从新根据屏幕宽度及高度设置了相关属性的值,如padding、margin、height、width、textSize。

固然,AndroidAutoLayout的上一次提交代码已是在4 yeas ago,在它的设计之初,是假定的认为全部的手机的高宽比都是在一个恰当的范围,好比720x1280,因此它的高宽都分别进行了不一样比例的缩放适配。然而,9102年,手机的高宽比显然已经多种多样。因此,AndroidAutoLayout已经进入了它的局限性。可是,它依旧是咱们能够借鉴的目标,咱们只须要将其高按照宽的缩放比例来缩放,或高或矮的手机自适应高度便可。(有兴趣的同窗能够尝试一下,这里只讲如何hook~)

那么,onMeasure中怎么hook呢?在AndroidAutoLayout中,它写了不少的自定义ViewGroup,好比AutoLinearLayout、AutoRelativeLayout、AutoFrameLayout,其实里面的代码都大同小异,咱们以AutoLinearLayout为例——

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (!isInEditMode()) { //这句代码不用管,用来判断是不是IDE预览模式的
        mHelper.adjustChildren();
    }
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
复制代码

能够看到,AndroidAutoLayout在super.onMeasure以前只作了一件事,就是adjustChildren,修改控件属性值。

public void adjustChildren() {
    AutoLayoutConifg.getInstance().checkParams(); //这句话不用管,用来检查库配置的

    for (int i = 0, n = mHost.getChildCount(); i < n; i++) {
        View view = mHost.getChildAt(i);
        ViewGroup.LayoutParams params = view.getLayoutParams();

        if (params instanceof AutoLayoutParams) {
            AutoLayoutInfo info = ((AutoLayoutParams) params).getAutoLayoutInfo();
            if (info != null) {
                info.fillAttrs(view);
            }
        }
    }
}
复制代码

而adjustChildren中循环取到了全部表层childView的AutoLayoutParams,AutoLayoutParams继承于父类LayoutParams,它也没干啥事,主要是将须要适配的属性(如textSize)存储起来。

public static AutoLayoutInfo getAutoLayoutInfo(Context context,AttributeSet attrs) {
    ...
    //原来设计的时候是和宽度的相关的属性按宽度缩放,和高度相关的属性按高度缩放。可是总有特例,因此baseWidth、baseHeight就是用来强制约束缩放方向的。
    int baseWidth = a.getInt(R.styleable.AutoLayout_Layout_layout_auto_basewidth, 0);
    int baseHeight = a.getInt(R.styleable.AutoLayout_Layout_layout_auto_baseheight, 0);
    ...
    for (int i = 0; i < n; i++) {
        ...
        switch (index) {
            case INDEX_TEXT_SIZE:
                info.addAttr(new TextSizeAttr(pxVal, baseWidth, baseHeight));
                break;
            case INDEX_PADDING:
                info.addAttr(new PaddingAttr(pxVal, baseWidth, baseHeight));
                break;
            ...
        }
    }
    return info;
}
复制代码

固然,不一样的AutoAttr会实现各自的缩放方法,其实就是很简单的计算出设计稿宽高和屏幕宽高的比值,而后和attribute的原始值相乘,获得最终的属性值。

public void apply(View view) {
    int val;
    if (useDefault()) {
        val = defaultBaseWidth() ? getPercentWidthSize() : getPercentHeightSize();
    } else if (baseWidth()) {
        val = getPercentWidthSize();
    } else {
        val = getPercentHeightSize();
    }
    if (val > 0) {
        val = Math.max(val, 1);//for very thin divider
    }
    execute(view, val);//执行缩放,并将其设置到view或者layoutParams中
}
复制代码

而apply在哪里被调用,其实就是在adjustChildren中的fillAttrs。

这样,hook就完成了。固然,AutoAttr、Helper、AutoLayoutParams以及内部封装的AutoLayoutActivity、AutoUtils,这些都是设计思想,重要的是实现思路,其实你也能够简单粗暴的糅合在一块儿,啊哈哈~

既然是完美适配,那就随便贴几个乱七八糟的分辨率吧~

按照惯例,如下是此节总结:

  1. 经过onMeasure做为切入点,基本能够完美适配屏幕,而且几乎没有性能损耗;
  2. 一样1也是缺点,必需要实现的viewGroup、attribute才支持;
  3. 对于自定义控件,适配工做会很繁重;
  4. 对于须要修改属性的控件,同3;
  5. 目前AndroidAutoLayout对于动态添加的控件不是很友好,须要添加完以后手动调用AutoUtils.auto(view),这会带来额外的开销,固然使用者能够自行拓展来支持这一点;
  6. 侵入性一样比较高,很是依赖技术人员的素养;
  7. 原来的库,高宽是以不一样比例缩放的,可是如今的手机高宽比差别都比较大,那么依照老规则显示效果会很是差,虽然能够强制指定控件的缩放方向,可是工做量会比较繁琐,所以可能须要本身修改一下源码;
  8. 固然,AndroidAutoLayout拦截替换的是px,若有须要,是能够换成拦截dp、sp的,这个不重要,重要的是hook点。

话说回来,其实onMeasure是一个浅层次的hook点,它虽然优势很明显,可是一样的缺点也很明显,那有没有一个切入点,既能够自动适配,又不用写这么多代码,侵入性也不高呢?字节跳动团队给了咱们答案

选择DisplayMetrics.densityDpi进行Hook

前面有讲到dp和px之间的关系,咱们能够知道:

1dp = 1px * dpi / 160
复制代码

而系统必定有一个地方是用来转换这些单位的,好比TypedValue中:

public static float applyDimension(int unit, float value,DisplayMetrics metrics)
{
    switch (unit) {
        case COMPLEX_UNIT_PX:
            return value;
        case COMPLEX_UNIT_DIP:
            return value * metrics.density;
        case COMPLEX_UNIT_SP:
            return value * metrics.scaledDensity;
        case COMPLEX_UNIT_PT:
            return value * metrics.xdpi * (1.0f/72);
        case COMPLEX_UNIT_IN:
            return value * metrics.xdpi;
        case COMPLEX_UNIT_MM:
            return value * metrics.xdpi * (1.0f/25.4f);
    }
    return 0;
}
复制代码

好比BitmapFactory中:

public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,@Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) {
    validate(opts);
    if (opts == null) {
        opts = new Options();
    }

    if (opts.inDensity == 0 && value != null) {
        final int density = value.density;
        if (density == TypedValue.DENSITY_DEFAULT) {
            opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
        } else if (density != TypedValue.DENSITY_NONE) {
            opts.inDensity = density; 
        }
    }
    
    //inDensity是指资源所在的drawable文件夹的密度,inTargetDensity是指屏幕密度
    if (opts.inTargetDensity == 0 && res != null) {
        opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
    }
    
    return decodeStream(is, pad, opts);
}

复制代码

咱们观察能够看到,他们都用到了DisplayMetrics.densityDpi这个属性,那么咱们只须要根据屏幕密度来修改这个属性值,就能够伪装屏幕永远为360dp的逻辑宽度。

而DisplayMetrics能够从三个地方获得:

// 系统的屏幕尺寸
val systemMetrics = Resources.getSystem().displayMetrics
// application的屏幕尺寸
val applicationMetrics = application.resources.displayMetrics
// activity的屏幕尺寸
val activityMetrics = activity.resources.displayMetrics
复制代码

咱们只须要修改application和activity的就行,system则建议不修改,用于保留一份原始数据,并且即便改了也没什么用,它是用于获取系统资源的。

从上文咱们能够很容易的得知当前屏幕的逻辑宽度是:

/**
 *  注意这里widthPixels不要用real width,缘由有二:
 *  1.available display width可能会小于real width,虽然大部分实际场景中是同样的;
 *  2.在1的场景中(好比说屏幕左右有个装饰栏?),咱们可能会出现,application中应用的是available display width,activity中应用的是real width,
 *  可是若是activity中使用了application.resource,那么此时间距大小会略小,这并无什么关系。
 *  反过来若是咱们修改的时候用的是real width,那么此时就会显示不下。
 */
val widthInDp = resources.displayMetrics.run {
    widthPixels / (densityDpi / 160) 
}
复制代码

那么咱们能够设置densityDpi为:

val targetDpi = resources.displayMetrics.widthPixels * 160 / 360  //360是咱们的设计稿的逻辑宽度
val sysMetrics = Resources.getSystem().displayMetrics

resources.displayMetrics.run {
    densityDpi = targetDpi
    density = targetDpi / 160f
    scaledDensity = density * sysMetrics.scaledDensity / sysMetrics.density //由于用户会修改字体大小,所以须要根据原比例来获得新的scaledDensity
}

application.resources.displayMetrics.run {
    densityDpi = targetDpi
    density = targetDpi / 160f
    scaledDensity = density * sysMetrics.scaledDensity / sysMetrics.density
}
复制代码

恢复的时候使用:

/**
 * 这里直接使用sysMetrics进行恢复,缘由有二
 * 1.不用记录中间值
 * 2.在使用应用时若是修改了系统字体大小,sysMetrics会同步修改,不用再监听registerComponentCallbacks
 */
val sysMetrics = Resources.getSystem().displayMetrics

resources.displayMetrics.run {
    densityDpi = sysMetrics.densityDpi
    density = sysMetrics.density
    scaledDensity = sysMetrics.scaledDensity
}

application.resources.displayMetrics.run {
    densityDpi = sysMetrics.densityDpi
    density = sysMetrics.density
    scaledDensity = sysMetrics.scaledDensity
}
复制代码

看看适配效果~

看起来perfect是否是?代码也很简单是否是?好像也没什么侵入性是否是?然而万物有利也有弊:

  1. 若是咱们修改了application.resource,若是三方库有用到,会受影响;
  2. 若是咱们是经过registerActivityLifecycleCallbacks修改的activity.resource,那么三方库的activity会受影响;
  3. 没法控制三方库可能也会同时修改;
  4. 系统控件也会受到影响,好比toast,因此特别不建议将设计稿的逻辑宽度设置的比较极端,好比为了从设计稿照抄省事,将其设置成1080dp;
  5. webView初始化的时候会还原density的值致使适配失效,须要修改以下:
    /**
     * 继承webView,复写此方法
     **/
    override fun setOverScrollMode(mode: Int) {
        super.setOverScrollMode(mode)
        adaptDensityDpi()
    }
    
    /**
     * 或者复写activity的此方法
     */
    override fun getResources(): Resources {
        adaptDensityDpi() //注意避免死循环及重复修改
        return super.getResources() 
    }
    复制代码
  6. 某些系统可能由于框架修改致使修改DisplayMetrics失效,好比MIUI7 + Android5.1.1;
  7. 须要考虑特殊activity不须要适配;
  8. 由于fragment其实用的是activity的resource,若是其中一个fragment不须要适配,那么须要考虑切换fragment时适配重置;

因此以上的代码其实只能应用于demo当中,以期证实咱们的方向是对的。剩下的咱们还须要进一步解决上述列出的这些问题,还须要适配,须要封装,须要提升易用性、健壮性~好比AndroidAutoSize

收笔

好了,本文到此结束。说了这么多,我的仍是比较倾向于最后一种方案。固然,一个方案的成熟是须要成长的,项目也是,人也是。其余的,仁者见仁,智者见智咯。


本文做者: timedance
本文连接: www.tktimedance.com/posts/a5563…
Demo地址: github.com/timedance/S… 版权声明: 本博客全部文章除特别声明外,均采用 BY-NC-ND 许可协议。转载请注明出处!

相关文章
相关标签/搜索