CrashDigger1.2介绍
author huizhong; QQ28073223
- 修订记录:
- 1.0:增加组件安全隐患测试内容
- 1.1:新增基础性能测试,包含流量、cpu、内存、电量信息;新增流畅性性能测试内容,支持测试页面帧率监控
组件crash隐患
android应用内部的Activity、Service、Broadcast Receiver等,他们通过Intent通信,组件间需要通信就需要在Androidmanifest.xml文件中暴露组件,前面提到的风险就有可能是不恰当的组件暴露引起的。每个组件都可在 AndroidManifest.xml 里通过属性 exported 被设置为私有或公有。私有或公有的默认设置取决于此组件是否被外部使用;例如某组件设置了 intent-filter 意味着该组件可以接收 intent,可以被其他应用访问.
Intent的两种基本用法:一种是显式的Intent,即在构造Intent对象时就指定接收者;另一种是隐式的Intent,即Intent的发送者在构造Intent对象时,并不知道也不关心接收者是谁,有利于降低发送者和接收者之间的耦合。显示调用和隐式调用都能过在不同应用间传递数据。
可能产生的风险:
1、恶意调用
2、恶意接受数据
3、仿冒应用,例如(恶意钓鱼,启动登录界面)
4、恶意发送广播、启动应用服务。
5、调用组件,接受组件返回的数据
6、拦截有序广播
【组件crash隐患使用方法】
- 安装被测试app以及crashDigger,启动后切换到「组件安全隐患测试」tab。点击开始测试
- 安装待测试平安系的app显示如下图
- 点击你要测试的app,如人寿进入。选择需要测试的组件以及实例类型,分别测试。
- 观察响应结果以及日志可能出现的问题有,crash,Anr, 假死(定屏,点击可退出),白屏等
出现白屏事例如下:
此处输入图片的描述ANR 事例如下
05-13 13:37:09.981: E/ActivityManager(1194): ANR in com.xfdream.pinganyh (com.xfdream.pinganyh/.yxapi.YXEntryActivity)
05-13 13:37:09.981: E/ActivityManager(1194): Reason: keyDispatchingTimedOut
05-13 13:37:09.981: E/ActivityManager(1194): Load: 2.18 / 1.98 / 1.93
05-13 13:37:09.981: E/ActivityManager(1194): CPU usage from 18490ms to 2205ms ago:
【crashDigger组件安全扫描原理】
crashDigger会扫描全部app外露接口,同时构造异常intent,统一播报给目标app,播报完成后即收集异常数据。对手机内已有的app产品全部暴露接口进行intent调起的遍历测试,测试对象包括外露的activity、broadcast、service、provider组件。
【实现方法】
http://developer.android.com/reference/android/content/pm/PackageManager.html
- PackageManger 类
Android提供了一个PackageManger管理类,它的主要职责是管理应用程序包。 通过它,我们就可以获取应用程序信息.
此处输入图片的描述
说明: 获得已安装的应用程序信息 。可以通过getPackageManager()方法获得Receiver,Provider,Activity,Services的Intent。
其他主要相关类介绍如下:
- PackageItemInfo类
说明: AndroidManifest.xml文件中所有节点的基类,提供了这些节点的基本信息:a label、icon、meta-data。它并不直接使用,而是由子类继承然后调用相应方法。
常用字段
public int icon获得该资源图片在R文件中的值 (对应于android:icon属性)
public int labelRes 获得该label在R文件中的值(对应于android:label属性)
public String name 获得该节点的name值 (对应于android:name属性)
public String packagename 获得该应用程序的包名 (对应于android:packagename属性)
常用方法:
Drawable loadIcon(PackageManager pm)获得当前应用程序的图像
CharSequence loadLabel(PackageManager pm)获得当前应用程序的label
- ActivityInfo类 继承自 PackageItemInfo
说明:
获得应用程序中<activity/>或者 <receiver />节点的信息 。我们可以通过它来获取我们设置的任何属性,包括theme 、launchMode、launchmode等
常用方法
继承自PackageItemInfo类中的loadIcon()和loadLabel()
- ServiceInfo 类
说明:
同ActivityInfo类似,同样继承自PackageItemInfo,只不过它表示的是<service>节点信息
- ApplicationInfo类 继承自 PackageItemInfo
说明:
获取一个特定引用程序中<application>节点的信息。
- ResolveInfo类
说明:
根据<intent>节点来获取其上一层目录的信息,通常是<activity>、<receiver>、<service>节点信息。
常用字段:
public ActivityInfo activityInfo获取ActivityInfo对象,即<activity>或<receiver >节点信息
public ServiceInfo serviceInfo获取ServiceInfo对象,即<service>节点信息
常用方法:
Drawable loadIcon(PackageManager pm) 获得当前应用程序的图像
CharSequence loadLabel(PackageManager pm) 获得当前应用程序的label
- PackageInfo类
说明:
手动获取AndroidManifest.xml文件的信息 。
常用字段:
public String packageName 包名
public ActivityInfo[] activities 所有<activity>节点信息
public ApplicationInfo applicationInfo <application>节点信息,只有一个
public ActivityInfo[] receivers 所有<receiver>节点信息,多个
public ServiceInfo[] services 所有<service>节点信息 ,多个
- PackageManger 类
说明: 获得已安装的应用程序信息。可以通过getPackageManager()方法获得。
常用方法:
public abstract PackageManager getPackageManager()
功能:获得一个PackageManger对象
public abstrac tDrawable getApplicationIcon(StringpackageName)
参数: packageName 包名
功能:返回给定包名的图标,否则返回null
public abstract ApplicationInfogetApplicationInfo(String packageName, int flags)
参数:packagename 包名
flags 该ApplicationInfo是此flags标记,通常可以直接赋予常数0即可功能:返回该ApplicationInfo对象
public abstract List<ApplicationInfo> getInstalledApplications(int flags)
参数:flag为一般为GET_UNINSTALLED_PACKAGES,那么此时会返回所有ApplicationInfo。我们可以对ApplicationInfo的flags过滤,得到我们需要的。
功能:返回给定条件的所有PackageInfo
public abstract List<PackageInfo> getInstalledPackages(int flags)
参数如上,
功能:返回给定条件的所有PackageInfo
public abstractResolveInfo resolveActivity(Intent intent, int flags)
参数: intent 查寻条件,Activity所配置的action和category flags: MATCH_DEFAULT_ONLY :Category必须带有CATEGORY_DEFAULT的Activity,才匹配GET_INTENT_FILTERS:匹配Intent条件即可GET_RESOLVED_FILTER :匹配Intent条件即可
功能 :返回给定条件的ResolveInfo对象(本质上是Activity)
public abstract List<ResolveInfo> queryIntentActivities(Intent intent, int flags)
参数同上
功能 :返回给定条件的所有ResolveInfo对象(本质上是Activity),集合对象
public abstract ResolveInfo resolveService(Intent intent, int flags)
参数同上
功能 :返回给定条件的ResolveInfo对象(本质上是Service)
public abstract List<ResolveInfo> queryIntentServices(Intent intent, int flags)
参数同上
功能 :返回给定条件的所有ResolveInfo对象(本质上是Service),集合对象
【修改建议和方法】
- 最小化组件暴露
不参与跨应用调用的组件添加android:exported="false"属性,这个属性说明它是私有的,只有同一个应用程序的组件或带有相同用户ID的应用程序才能启动或绑定该服务。
- <activity
- android:name=".LoginActivity"
- android:label="@string/app_name"
- android:screenOrientation="portrait"
- android:exported="false">
私有组件此时Activity只能被自身app启动。(同user id或者root也能启动)私有Activity不能被其他应用启动相对安全。
- 设置组件访问权限
组件添加android:permission属性。
- <activity android:name=".Another" android:label="@string/app_name"
- android:permission="com.test.custempermission">
</activity>
声明< permission>属性
1. <permission android:description="test"
2. android:label="test"
3. android:name="com.test.custempermission"
4. android:protectionLevel="normal">
5. </permission>
****protectionLevel有四种级别normal、dangerous、signature、signatureOrSystem。signature、signatureOrSystem时,只有相同签名时才能调用。****
调用组件者声明<uses-permission>
<uses-permission android:name="com.test.custempermission" />
- 暴露组件的代码检查
Android 提供各种 API 来在运行时检查、执行、授予和撤销权限。这些 API是 android.content.Context 类的一部分,这个类提供有关应用程序环境的全局信息。
- if (context.checkCallingOrSelfPermission("com.test.custempermission")
- != PackageManager.PERMISSION_GRANTED) {
- // The Application requires permission to access the
- // Internet");
- } else {
- // OK to access the Internet
- }
- Activity安全
创建 Activity时:
1、不指定 taskAffinity //task 管理 Activity。task 的名字取决于根 Activity的 affinity。默认设置中 Activity 使用包名做为 affinity。task 由 app 分配,所以一个应用的 Activity 在默认情况下属于相同 task。跨 task 启动 Activity 的 intent 有可能被其他 app 读取到。
2、不指定 lanchMode //默认 standard,建议使用默认。创建新 task 时有可能被其他应用读取 intent的内容。
3、设置 exported 属性为false
4、谨慎处理从 intent 中接收的数据,不管是否内部发送的 intent
5、敏感信息只能在应用内部操作
使用 Activity时:
6、开启Activity时不设置 FLAG_ACTIVITY_NEW_TASK 标签//FLAG_ACTIVITY_NEW_TASK 标签用于创建新 task(被启动的 Activity 并未在栈中)。
7、开启应用内部 Activity 使用显示启动的方式
8、当 putExtra() 包含敏感信息目的应是 app 内的 Activity
9、谨慎处理返回数据,即可数据来自相同应用
公开暴露的 Activity 组件,可以被任意应用启动:
创建 Activity:
1、设置 exported 属性为 true
2、谨慎处理接收的 intent
3、有返回数据时不应包含敏感信息
使用 Activity:
4、不应发送敏感信息
5、当收到返回数据时谨慎处理
- Service 安全
通常 Service 执行的操作比较敏感,如更新数据库,提供事件通知等,因此一定要确保访问 Service 的组件有一定权限(也就是给 Service 设置权限)。
在 AndroidManifest.xml 里给 Service 设置权限(可自定义)。一般设置 exported 属性为 false(或没有 intent-filter);如果需要给别的 app 访问即此属性设置为true,最好做敏感操作的时候通过checkCallingPermission() 方法来提供权限检测。
不要轻易把 Intent 传递给公有的未知名的 Service;最好在所传递的 Intent 中提供完整类名,或者在 ServiceConnection的onServiceConnected(ComponentName, Ibinder)里检验 Service 的包名。
- Content Provider 安全
Content Provider 为其他不同应用程序提供数据访问方式,需要更复杂的安全措施保护。读写权限分开。一旦应用程序来访,Content Provider需要对权限检测,只有拥有只读/只写权限才允许建立连接,否则抛出 SecurityException。
只读/只写一旦实施就适用于所有数据,如果播放器只要特定音乐数据,给播放器全部访问权限,岂不是权限很大,为了细分权限粒度,可以使用 Grant-uri-permission 机制来指定一个数据集。Content Provider 可通过属性 <grant-uri-permission> 为其内的 URI 设置临时访问权限。
- Broadcast Receiver 安全
应用通常用它来监听广播消息。
广播发送方通常选择给每个发送 Broadcast Intent 授予 Android 权限;接收方不但需要符合 Intent filter 的接收条件,还要求 Broadcast Receiver 也必须具有特定权限(给发送方授予权限要一致)才能接收(双层过滤)。
基础性能测试
【基础性能测试使用方法】
- 启动后切换到「基础性能」tab。点击开始测试
- 选择需要测试的app,点击开始测试
- 启动后开始执行操作,wifi ON按键用于控制wifi的关闭和开启;STOP按键可以控制关闭测试;RESET用于清除当前的数据(扫描间隔一秒,点击之后一秒之后可以看到新数据);浮层可以拖拽到你想要它存在的位置。---该部分直接复用了emmage app的功能,当前可能有兼容性问题存在
流畅度测试
流畅度是衡量app页面渲染时,是否流畅的测试。图片处理器每秒刷新的帧数(FPS),可用来指示页面是否平滑的渲染。我们看到的动态画面,是一帧帧静态画面联动起来后达到的。这利用了人眼的视觉暂留。
一秒内静态画面越多,我们眼睛的感觉就越流畅。静态画面的数量,我们叫帧数。我们看到的电影是24帧到29帧,就是一秒钟24幅静态画面,因为电影的每一帧都是模糊帧,包含一定的时间信息,所以24帧我们看着就很流畅了。
【流畅度测试使用方法】
- 启动后切换到「流畅度」tab。点击开始测试
- 选择需要测试的app,点击开始测试。
- 启动后开始执行操作,可以点击按键选择开始和停止测试。浮层可以拖拽到你想要它存在的位置,页面上显示当前帧率,平均帧率,最大帧率以及过去一段时间的最低帧率。最低帧率突然下降到30以下时。疑似出现页面卡顿。
分辨率兼容性测试
兼容性么,总的来说有这么几项。网络兼容性,厂商兼容性(包含cpu类型),平台兼容性。
兼容性测试就是检查软件在一个特定的硬件、软件、操作系统、网络等环境下是否能够正常地运行,检查软件之间是否能够正确地交互和共享信息。
硬件兼容性指标
- 分辨率兼容性
智能手机分辨率的兼容性测试主要关注的是程序不同页面,弹窗,提示等在不同分辨率下显示的是否正常(包括横竖屏的切换)。
主要考虑因素如下:
a. 分辨率市场占有率topN,参考和产品线PM沟通的结果。
b. 产品用户所用分辨率topN
- 机型兼容性
需要保证市场占用率较大的厂商机器可以正常执行产品功能,比如三星、htc,同时结合市场占有率和产品线的数据统计确定测试机型。
主要考虑因素如下:
a. 主流机型的市场占有率的top3
b. 产品线用户机型的top3
c. 特殊厂商,特殊机型。
- 操作系统兼容性
不同的操作系统及同一操作系统不同版本对程序处理方式可能会不同,所以要针对产品对操作系统做兼容性测试。
主要考虑因素如下:
a. 操作系统原生版市场占有率的top3
b. 特殊厂商,自己制作了适合自己手机的rom,比如小米系统、魅族、CM等,与原生系统略有些不一样。
c. android测试,大版本覆盖到小数点后一位,如2.3.X,4.1.X,5.1.X,小版本覆盖到整数位,2.X,4.X,5.X;IOS同Android,但作为最新的系统版本必测。
软件兼容性指标
主要考察两项内容:一是软件运行需要哪些其他应用软件的支持,二是判断与其他常用软件如MS OFFICE,反病毒软件一起使用,是否造成其他软件运行错误或软件本身不能正确实现其功能。
与其他软件交互时主要有以下三个部分:
兼容类型 | 预期 |
---|---|
平安APP系列:口袋银行,平安好车主 …… | 相互无冲突 |
监控类产品:网秦、360手机卫士 | 相互无冲突 |
聊天类产品:QQ、飞信 | 相互无冲突 |
浏览器产品:UC | 相互无冲突 |
输入法产品:QQ输入法、搜狗输入法 | 相互无冲突 |
系统工具:蓝牙助手、高级任务管理、缓存助手 | 相互无冲突 |
娱乐播放类:豆瓣电台、酷我听听、天天、优酷、QQ音乐 | 相互无冲突 |
网络兼容性指标
网络兼容性,是保证各种网络环境能够够完全覆盖,排除由于不同网络网关特殊处理导致产品功能不可用情况,同时也需要检查在各种网络下的用户体验问题。主要包括移动2g,3g,4g,联通2g,3g,4g,有鉴权的wifi和无鉴权的wifi
数据兼容性
通常指不同版本间的数据兼容性,主要检查低版本程序运行产生的数据在安装新版本后是否可用,或者正常升级的情况下,用户原始数据是否丢失。
上面废话说这么多,无非想说兼容性要测试的机器还是蛮多的,往往出现各种借机器等机器,一个验证只要十分钟的问题却花几个小时找机器,实在不划算。
cpu和厂商相关的,只能依赖于手机这个不谈。
先来介绍一下wm,因为代码不多所以下面全贴出了。
/*
**
** Copyright 2013, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
package com.android.commands.wm;
import android.content.Context;
import android.graphics.Point;
import android.graphics.Rect;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.util.AndroidException;
import android.view.Display;
import android.view.IWindowManager;
import com.android.internal.os.BaseCommand;
import java.io.PrintStream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Wm extends BaseCommand {
private IWindowManager mWm;
/**
* Command-line entry point.
*
* @param args The command-line arguments
*/
public static void main(String[] args) {
(new Wm()).run(args);
}
public void onShowUsage(PrintStream out) {
out.println(
"usage: wm [subcommand] [options]\n" +
" wm size [reset|WxH]\n" +
" wm density [reset|DENSITY]\n" +
" wm overscan [reset|LEFT,TOP,RIGHT,BOTTOM]\n" +
"\n" +
"wm size: return or override display size.\n" +
"\n" +
"wm density: override display density.\n" +
"\n" +
"wm overscan: set overscan area for display.\n"
);
}
public void onRun() throws Exception {
mWm = IWindowManager.Stub.asInterface(ServiceManager.checkService(
Context.WINDOW_SERVICE));
if (mWm == null) {
System.err.println(NO_SYSTEM_ERROR_CODE);
throw new AndroidException("Can't connect to window manager; is the system running?");
}
String op = nextArgRequired();
if (op.equals("size")) {
runDisplaySize();
} else if (op.equals("density")) {
runDisplayDensity();
} else if (op.equals("overscan")) {
runDisplayOverscan();
} else {
showError("Error: unknown command '" + op + "'");
return;
}
}
private void runDisplaySize() throws Exception {
String size = nextArg();
int w, h;
if (size == null) {
Point initialSize = new Point();
Point baseSize = new Point();
try {
mWm.getInitialDisplaySize(Display.DEFAULT_DISPLAY, initialSize);
mWm.getBaseDisplaySize(Display.DEFAULT_DISPLAY, baseSize);
System.out.println("Physical size: " + initialSize.x + "x" + initialSize.y);
if (!initialSize.equals(baseSize)) {
System.out.println("Override size: " + baseSize.x + "x" + baseSize.y);
}
} catch (RemoteException e) {
}
return;
} else if ("reset".equals(size)) {
w = h = -1;
} else {
int div = size.indexOf('x');
if (div <= 0 || div >= (size.length()-1)) {
System.err.println("Error: bad size " + size);
return;
}
String wstr = size.substring(0, div);
String hstr = size.substring(div+1);
try {
w = Integer.parseInt(wstr);
h = Integer.parseInt(hstr);
} catch (NumberFormatException e) {
System.err.println("Error: bad number " + e);
return;
}
}
try {
if (w >= 0 && h >= 0) {
// TODO(multidisplay): For now Configuration only applies to main screen.
mWm.setForcedDisplaySize(Display.DEFAULT_DISPLAY, w, h);
} else {
mWm.clearForcedDisplaySize(Display.DEFAULT_DISPLAY);
}
} catch (RemoteException e) {
}
}
private void runDisplayDensity() throws Exception {
String densityStr = nextArg();
int density;
if (densityStr == null) {
try {
int initialDensity = mWm.getInitialDisplayDensity(Display.DEFAULT_DISPLAY);
int baseDensity = mWm.getBaseDisplayDensity(Display.DEFAULT_DISPLAY);
System.out.println("Physical density: " + initialDensity);
if (initialDensity != baseDensity) {
System.out.println("Override density: " + baseDensity);
}
} catch (RemoteException e) {
}
return;
} else if ("reset".equals(densityStr)) {
density = -1;
} else {
try {
density = Integer.parseInt(densityStr);
} catch (NumberFormatException e) {
System.err.println("Error: bad number " + e);
return;
}
if (density < 72) {
System.err.println("Error: density must be >= 72");
return;
}
}
try {
if (density > 0) {
// TODO(multidisplay): For now Configuration only applies to main screen.
mWm.setForcedDisplayDensity(Display.DEFAULT_DISPLAY, density);
} else {
mWm.clearForcedDisplayDensity(Display.DEFAULT_DISPLAY);
}
} catch (RemoteException e) {
}
}
private void runDisplayOverscan() throws Exception {
String overscanStr = nextArgRequired();
Rect rect = new Rect();
int density;
if ("reset".equals(overscanStr)) {
rect.set(0, 0, 0, 0);
} else {
final Pattern FLATTENED_PATTERN = Pattern.compile(
"(-?\\d+),(-?\\d+),(-?\\d+),(-?\\d+)");
Matcher matcher = FLATTENED_PATTERN.matcher(overscanStr);
if (!matcher.matches()) {
System.err.println("Error: bad rectangle arg: " + overscanStr);
return;
}
rect.left = Integer.parseInt(matcher.group(1));
rect.top = Integer.parseInt(matcher.group(2));
rect.right = Integer.parseInt(matcher.group(3));
rect.bottom = Integer.parseInt(matcher.group(4));
}
try {
mWm.setOverscan(Display.DEFAULT_DISPLAY, rect.left, rect.top, rect.right, rect.bottom);
} catch (RemoteException e) {
}
}
}
其中这段简单的介绍了如何使用wm
public void onShowUsage(PrintStream out) {
out.println(
"usage: wm [subcommand] [options]\n" +
" wm size [reset|WxH]\n" +
" wm density [reset|DENSITY]\n" +
" wm overscan [reset|LEFT,TOP,RIGHT,BOTTOM]\n" +
"\n" +
"wm size: return or override display size.\n" +
"\n" +
"wm density: override display density.\n" +
"\n" +
"wm overscan: set overscan area for display.\n"
);
}
可以看出wm大概提供了几样基本功能:
- wm size:返回当前屏幕的分辨率,单纯运行wm size命令将会得到lcd本身设置的显示分辨率。
wm size W x H命令是按witch x hight 设置分辨率。
wm size reset 命令是将分辨率设置为原始分辨率。
C02PC5DQFVH5:~ rodmanliu$ adb shell wm size
Physical size: 1080x1920
-
wm density:该命令的用法类似于wm size 命令,作用是读取、设置或者重置屏幕的density值
-
wm overscan [reset|LEFT,TOP,RIGHT,BOTTOM]
该命令用来设置、重置LCD的显示区域。四个参数分别是显示边缘距离LCD左、上、右、下的像素数。例如,对于分辨率为540x960的屏幕,通过执行 命令wm overscan 0,0,0,420可将显示区域限定在一个540x540的矩形框里。了解wm可以解决LCD图标大小显示不正常的问题。但是这些设置都是临时的,适合于调试来确定问题和解决办法。
不过还是有一个问题,有些手机上没有封装wm类,你再使用时会遇到如下提示:
C02PC5DQFVH5:~ rodmanliu$ adb shell
root@android:/ # vm size
/system/bin/sh: vm: not found
还好这时我们可以招到另外一种方法adb shell am display-size
功效是接近的。修改分辨率可以如下操作
adb shell am display-size 720x1280 \\修改分辨率为720x1280
adb shell am display-size reset \\恢复默认设置
后面的分辨率最好不要超过屏幕本身分辨率,否则可能会超出屏幕(当然了我说的是可能)。
如下,启动cmd,查看当前分辨率,可以看到当前的分辨率是768x1280
C02PC5DQFVH5:~ rodmanliu$ adb shell dumpsys window -w | grep cur=
init=768x1280 320dpi cur=768x1280 app=768x1184 rng=768x718-1196x1134
尝试修改分辨率640x960,如下图可见修改后,查看当前分辨率,cur一项已经能看到是640x960
C02PC5DQFVH5:~ rodmanliu$ adb shell am display-size 640x960
C02PC5DQFVH5:~ rodmanliu$ adb shell dumpsys window -w | grep cur=
init=768x1280 320dpi base=640x960 320dpi cur=640x960 app=640x864 rng=640x590-876x814
为了方便大家不用每次都在电脑上操作,crashdigger1.2集成了分辨率修改的功能。
打开crashdigger,切换到分辨率tab,点击"开始测试"
此处输入图片的描述注意,因为该操作需要root权限,所以要确定你的手机已经root,如果没有root将无法进度下面功能
此处输入图片的描述如上点击你需要切换的分辨率,开始做分辨率兼容性测试吧
以下是一账通切换小分辨率时候状况,还是不少问题的
此处输入图片的描述比如上图,文字都立起来了。
此处输入图片的描述比如这个,内容已经堆叠在了一起。
如果你的手机比较ok,还可以直接切换很高的分辨率,比如1440x2560. 手机瞬间变pad有没有。这下连超大分辨率的适配也做了,省去好几千买新机器的钱呢。
此处输入图片的描述
网友评论