HashMap 和 HashTable 区别

相同点

  • 都是以 Key - Value 的形式存放键值对

不同点

  • null 支持:HashMap 允许 null key 和 null value,HashTable 不允许
  • 并发特性:HashMap 线程不安全,效率高,HashTable 线程安全,效率低
  • 默认长度和扩容方式:HashMap 默认长度 16,扩容 2n,HashTable 默认长度 11,扩容 2n+1
  • 父类:HashMap 父类 AbstractMap,其子类还有 ConcurrentHashMap、LinkedHashMap 等,HashTable 父类 Dictionary,子类有 Properties

结论

  • 不需要考虑线程安全,用 HashMap
  • 需要考虑线程安全,用 ConcurrentHashMap

JVM 垃圾回收机制/GC

含义:由 JVM 自动回收那些不再使用的对象,清理内存

意义:程序员不需要人工管理内存,减少开发成本,提高开发效率

基础知识

对象实例存在于 Java 堆中

Java 中对对象的引用分成“强软弱虚”

  • 强引用,最普遍的引用,只要有强引用存在,对象就不会被回收
  • 软引用,SoftReference,当 GC 时,如果内存不足,会被回收
  • 弱引用,WeakReference,当 GC 时,不论内存是否足够,都被回收
  • 虚引用,PhantomReference,不影响对象的生命周期,在任何时刻都可能被回收
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ReferenceDemo {
public static void main(String[] arge) {
//强引用
Object object = new Object();
Object[] objects = new Object[100];

//软引用
SoftReference<String> stringSoftReference = new SoftReference<>(new String("SoftReference"));
System.out.println(stringSoftReference.get());
System.gc();
System.out.println(stringSoftReference.get()); //手动GC,这时内存充足,对象没有被回收

System.out.println();

//弱引用
WeakReference<String> stringWeakReference = new WeakReference<>(new String("WeakReference"));
System.out.println(stringWeakReference.get());
System.gc();
System.out.println(stringWeakReference.get()); //手动gc,这时,返回null,对象已经被回收

System.out.println();

//虚引用
//虚引用主要用来跟踪对象被垃圾回收器回收的活动。
//虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。
//当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中
ReferenceQueue<String> stringReferenceQueue = new ReferenceQueue<>();
PhantomReference<String> stringPhantomReference = new PhantomReference<>(new String("PhantomReference"), stringReferenceQueue);
System.out.println(stringPhantomReference.get());
}
}

引用计数器

每个对象拥有一个计数器,当它被引用时,计数器 +1,引用释放时,计数器 -1,当计数器为 0 时,表示可以回收。

存在的问题是循环引用。为了解决这个问题,又引入了“可达性”(GC Roots Tracing)的概念,目前主流的 JVM 都采用了这种计数。简单说,就是从根部开始向下搜索,如果对象无法被触及,则认为是可以回收的,这种对象称为“不可达对象”。

看一下 JVM 运行时的内存结构:

JVM
JVM

GC Roots 包括

  • 虚拟机栈的栈帧中的引用对象(来自局部变量表)
  • 方法区静态属性实体引用的对象
  • 方法区的常量引用对象
  • 本地方法栈中 JNI 引用的对象
  • 存活 Thread 引用的对象

在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:

  • 如果对象在进行根搜索后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法
  • 当对象没有覆盖 finalize() 方法,或 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行
  • 如果该对象被判定为有必要执行 finalize() 方法,那么这个对象将会被放置在一个名为 F-Queue 队列中,并在稍后由一条由虚拟机自动建立的、低优先级的 Finalizer 线程去执行 finalize() 方法
  • finalize() 方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize() 方法最多只会被系统自动调用一次), 稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize() 方法中让该对象重引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉

垃圾收集算法

标记-清除 算法

分为“标记”和“清除”两部,首先标记出需要回收的对象,然后在第二步清除它们。是最基础的回收算法,后续算法都是基于它的基础上进行改进。

标记-清除
标记-清除
  • 效率问题:需要两次扫描
  • 空间问题:产生大量内存碎片

复制 算法

将可用内存平均分为2块,每次只使用其中的一块。当一块内存使用完成后,将存活对象复制到另一块内存中,然后清空。

复制
复制

优点

  • 每次只操作一块内存,分配时无需要考虑内存碎片情况,只移动指针即可,实现简单,运行高效

缺点

  • 利用率问题:可用内存少了一半
  • 效率问题:老生代对象由于存活率高,频繁复制

标记-压缩 算法

标记后,将所有存活对象向一端移动

标记-压缩
标记-压缩

优点

  • 对于老年代,会逐渐移动到头部

缺点

  • 新生代对象多的话,会频繁移动

分代收集 算法

对新生代采用复制算法(Minor GC),老年代采用标记压缩算法(Major GC),全部回收称为 Full GC。

分代收集
分代收集
  • 年轻代: 是所有新对象产生的地方.年轻代被分为3个部分(Enden区和两个Survivor区,也叫From和To),当Eden区被对象填满时,就会执行Minor GC,并把所有存活下来的对象转移到其中一个survivor区(Form),Minor GC同样会检查存活下来的对象,并把它们转移到另一个survivor区(To),这样在一段时间内,总会有一个空的survivor区,经过多次GC周期后,仍然存活下来的对象会被转移到年老代内存空间,常这是在年轻代有资格提升到年老代前通过设定年龄阈值来完成的,需要注意,Survivor的两个区是对称的,没先后关系,from和to是相对的.
  • 老年代: 在年轻代中经历了N次回收后仍然没有被清除的对象,就会被放到年老代中,都是生命周期较长的对象.对于年老代,则会执行Major GC,来清理.在某些情况下,则会触发Full GC,来清理整个堆内存
  • 元空间: 堆外的一部分内存,通常直接使用的是系统内存,用于存放运行时常量池,等内容,垃圾回收对应元空间来说没有明显的影响

参考:jvm - 垃圾回收

使用OkHttp同时发送3个请求,token过期如何处理

这是面试威佩时的一道面试题

这与发几个请求无关,解决问题的点在于发现 token 过期后如何自动获取 token 并重发请求,也就是,静默自动登录,然后继续请求。解决思路是在拦截器链中增加一个 TokenInterceptor,判断返回状态是否为验证失效。

  1. 发送请求给服务端
  2. 根据返回状态码判断是否 token 过期
  3. 如果过期,则调取同步接口获取新 token
  4. 使用新 token 发送请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class TokenInterceptor implements Interceptor {

private static final String TAG = "TokenInterceptor";

@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
Log.d(TAG, "response.code=" + response.code());

//根据和服务端的约定判断token过期
if (isTokenExpired(response)) {
Log.d(TAG, "自动刷新Token,然后重新请求数据");
//同步请求方式,获取最新的Token
String newToken = getNewToken();
//使用新的Token,创建新的请求
Request newRequest = chain.request()
.newBuilder()
.header("Authorization", "Basic " + newToken)
.build();
//重新请求
return chain.proceed(newRequest);
}
return response;
}

// 根据Response,判断Token是否失效
private boolean isTokenExpired(Response response) {
if (response.code() == 301) {
return true;
}
return false;
}

// 同步请求方式,获取最新的Token
private String getNewToken() throws IOException {
// 通过获取token的接口,同步请求接口
String newToken = "";
return newToken;
}
}

如果是面试官问的“三个请求同时发送”,那么可以在 token 上增加一个时间戳,通过这个时间戳可以判断该 token 是否为更新过后的。那么,在 A、B、C 三个请求同时发出时,当 A 发现 token 过期并更新 token 后,B 和 C 可以读取新的 token 发送请求,而不必再向服务端获取新的 token。

一点点反思

面有赞的那一天自己刚刚撸过 OkHttp 的源码,还写了文字总结,但是被问到这道题时仍然一脸懵逼。问题在于对 OkHttp 以 Interceptor 为核心的根本思想没有把握,其实这道题是稍微有一点小花招。低配版的问法是“如何解决 token 过期”,这就很容易联想到 Interceptor(其实也不容易),高配版才是问“同时三个请求”,需要结合同步来解决。

参考 Android OkHttp实现全局过期token自动刷新示例

RecyclerView

同样是威佩的面试题

The RecyclerView widget is a more advanced and flexible version of ListView.

RecyclerView 支持多种 Layout,如 LinearLayout、GridLayout。使用 RecyclerView 时需要继承 RecyclerView.ViewHolder 类,如果数据发生变化,调用 RecyclerView.Adapter.notify…() 方法。示例代码如下:

gradle

1
2
3
dependencies {
implementation 'com.android.support:recyclerview-v7:27.1.1'
}

布局文件,没啥特别的,除了需要声明一个 Scrollbar 的方向

1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<!-- A RecyclerView with some commonly used attributes -->
<android.support.v7.widget.RecyclerView
android:id="@+id/my_recycler_view"
android:scrollbars="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

Activity,跟 ListView 不同点在:

  • 如果 RecyclerView 的尺寸不会发生变化,要调用setHasFixedSize(true)来提高性能
  • 要根据布局设置 LayoutManager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class MyActivity extends Activity {
private RecyclerView mRecyclerView;
private RecyclerView.Adapter mAdapter;
private RecyclerView.LayoutManager mLayoutManager;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.my_activity);
mRecyclerView = (RecyclerView) findViewById(R.id.my_recycler_view);

// use this setting to improve performance if you know that changes
// in content do not change the layout size of the RecyclerView
mRecyclerView.setHasFixedSize(true);

// use a linear layout manager
mLayoutManager = new LinearLayoutManager(this);
mRecyclerView.setLayoutManager(mLayoutManager);

// specify an adapter (see also next example)
mAdapter = new MyAdapter(myDataset);
mRecyclerView.setAdapter(mAdapter);
}
// ...
}

Adapter,与 ListView 的写法差异较大。

  • 需要继承自 RecyclerView.Adapter<MyAdapter.ViewHolder>
  • 必须声明一个静态内部类 ViewHolder
  • 重载 onCreateViewHolder 方法,需要生成布局,初始化 ViewHolder 并返回
  • 重载 onBindViewHolder 方法,这一步是将 ViewHolder 中的 View 都赋予正确的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class MyAdapter extends RecyclerView.Adapter<MyAdapter.ViewHolder> {
private String[] mDataset;

// Provide a reference to the views for each data item
// Complex data items may need more than one view per item, and
// you provide access to all the views for a data item in a view holder
public static class ViewHolder extends RecyclerView.ViewHolder {
// each data item is just a string in this case
public TextView mTextView;
public ViewHolder(TextView v) {
super(v);
mTextView = v;
}
}

// Provide a suitable constructor (depends on the kind of dataset)
public MyAdapter(String[] myDataset) {
mDataset = myDataset;
}

// Create new views (invoked by the layout manager)
@Override
public MyAdapter.ViewHolder onCreateViewHolder(ViewGroup parent,
int viewType) {
// create a new view
TextView v = (TextView) LayoutInflater.from(parent.getContext())
.inflate(R.layout.my_text_view, parent, false);
...
ViewHolder vh = new ViewHolder(v);
return vh;
}

// Replace the contents of a view (invoked by the layout manager)
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
// - get element from your dataset at this position
// - replace the contents of the view with that element
holder.mTextView.setText(mDataset[position]);

}

// Return the size of your dataset (invoked by the layout manager)
@Override
public int getItemCount() {
return mDataset.length;
}
}

LayoutManager

Android Support Library 自带的有三种,你也可以通过继承 RecyclerView.LayoutManager 来实现自己的布局。

  • LinearLayoutManager,一维线性列表,同 ListView
  • GridLayoutManager,网格列表,同 GridView
  • StaggeredGridLayotManager,瀑布流列表,列之间有错位

动画

Item 变化的时候,RecyclerView 使用 animator 来改变外观,animator 继承自 RecyclerView.ItemAnimator。

List-Item Selection

这部分略,日后写 Demo 补充

RecyclerView 几大重要成员

RecyclerView Components
RecyclerView Components

RecyclerView 缓存

  • 内部两级缓存
  • 划出界面的 ViewHolder 会被放入 Cache(一级缓存),容量为2。Cache 中的对象都是同种 ViewType。
  • 从 Cache 中被清除的对象,会被放入 RecycledViewPool,容量为5。RecycledViewPool 中的对象按照 ViewType 分类。
RecyclerView Cache
RecyclerView Cache

参考

微信的“分享”页面使用的是哪种启动模式

这是面试有赞时,面试官提问的第一道题目。直觉告诉我 Standard 和 SingleTop 都不适用,但是并不能从 SingleInstance 和 SingleTask 中选出一个合适的来,这一块是知识盲区,以前根本没有从实用的角度来考虑这问题,都是死记硬背启动模式。

本质上还是考察启动模式的应用场景。

SingleInstance

与外部应用共享的页面,一般设置成这种启动模式,也就是作为外部App调用自己客户端程序的入口。这是为了方便其它应用的 Activity 调起本应用。同时,由于 SingleInstance 会单独起一个 Task,当用户操作完成该页面后,点击返回按钮,会自动退回到外部应用。在使用时应该注意声明taskAffinity,以便在任务管理中看到新 Task。

1
2
3
4
<activity
android:name=".SingleInstanceActivity"
android:label="singleInstance launchMode"
android:launchMode="singleInstance"/>

SingleTask

一个 Task 内只允许有一个 SingleTask 的 Activity,启东时如果 Task 内已经有了同 Activity,则会将其上所有的 Activity 按照生命流程进行销毁,同时调用该 Activity 的onNewIntent方法。适合的应用场景是应用内部统一入口,比如浏览器首页、商户详情页等。同样,需要声明taskAffinity以保证它会新起一个 Task。

SingleTop

同样,如果 Task 顶已经有了同一个 Activity,会调用onNewIntent传入参数。

应用场景:

  • 点击“通知”后打开的详情页面
  • 浏览器搜索结果页,带有关键词输入框

Standard

应用于可以打开多个实例的页面,比如与不同人的聊天页面,撰写邮件页面等。

场景:应用进程被杀后再次启动

设想一个这样的应用场景:用户在使用你的应用时,突然接收到一个微信消息,他跳转到微信后,你的应用被切换至后台运行。该用户在微信中翻阅了朋友圈、查看一些视频,以及进行了其它很多吃内存的操作,导致系统内存紧张,这时你的应用进程被杀死。但是由于Android系统仍然会保持 Task 栈的内容,所以用户在“最近应用”里仍然是可以看到你的应用的。此时如果用户通过“最近应用”切换回你的应用(进程已经被杀死),极易发生异常导致闪退。

异常原因在于当进程被杀死后,所有的静态常量值会被清空,如果在此时使用并且未经检查,很容易出现 NPE 等异常。常规解决方法是在 onSaveInstance 里面对变量进行保存,然后在 onRestoreInstance 里恢复变量值。缺点是工作量大,代码冗长。

此时的一个解决思路是对于进程被杀死的情况,如果再次启动,则跳转回应用首页(这点需要获取产品经理同意),随后的一切等同于首次启动。

相应的技术思路是,将首页启动模式声明为 SingleTask,同时写一个 BaseActivity,它里面有一个 getAppStatus 方法用来判断应用是否处于回收后重新启动的状态,判断方法是读取一个静态 int 类型变量 appStatus,它默认值是 KILLED,在每一个子 Activity 的 onCreate 方法里将其设置成 NORMAL 值。这样一旦被回收,它就会变成 KILLED,就可以在 BaseActivity.onCreate 里通过对这个变量的判断,决定是不是要重新回到首页。

参考:如何让你的app在后台被干掉后优雅的启动

IntentService 实现原理

通过startService(Intent)来启动一个 IntentService,它内部有一个工作队列(Worker Thread),在工作线程内运行,不会影响 UI 线程。IntentService 是一个抽象类,继承它时必须实现onHandleIntent方法。

  • 普通 Service 由于运行在 UI 线程,无法进行耗时操作,IntentService 解决了这个问题
  • 运行完成后,IntentService 会自动停止

从原理上讲,IntentService 是创建了一个 HandlerThread,然后用 thread.getLooper() 赋给 Handler,这个 Handler 接收 onStart 时传来的 Intent,并以此 Intent 调用你所覆盖的 onHandleIntent 方法。任务完成后,可以用广播或者 EventBus 等手段通知调用者。

参考:IntentService的原理和实例分析

HTTP 请求和响应头的格式

有赞和威佩都问到了这一题。

HTTP的头域包括通用头,请求头,响应头和实体头四个部分。每个头域由一个域名,冒号(:)和域值三部分组成。域名是大小写无关的,域值前可以添加任何数量的空格符,头域可以被扩展为多行,在每行开始处,使用至少一个空格或制表符。

通用头

通用头域包含请求和响应消息都支持的头域,通用头域包含Cache-Control、Connection、Date、Pragma、Transfer-Encoding、Upgrade、Via。

请求头

请求消息的第一行为下面的格式:

1
Method SP Request-URI SP HTTP-Version CRLF

请求头域允许客户端向服务器传递关于请求或者关于客户机的附加信息。请求头域可能包含下列字段Accept、Accept-Charset、Accept- Encoding、Accept-Language、Authorization、From、Host、If-Modified-Since、If- Match、If-None-Match、If-Range、If-Range、If-Unmodified-Since、Max-Forwards、 Proxy-Authorization、Range、Referer、User-Agent。

响应头

响应消息的第一行为下面的格式:

1
HTTP-Version SP Status-Code SP Reason-Phrase CRLF

常见错误码

  • 1xx:信息响应类,表示接收到请求并且继续处理
  • 2xx:处理成功响应类,表示动作被成功接收、理解和接受
  • 3xx:重定向响应类,为了完成指定的动作,必须接受进一步处理
  • 4xx:客户端错误,客户请求包含语法错误或者是不能正确执行
  • 5xx:服务端错误,服务器不能正确执行一个正确的请求

实体信息

请求消息和响应消息都可以包含实体信息,实体信息一般由实体头域和实体组成。实体头域包含关于实体的原信息,实体头包括Allow、Content-Base、Content-Encoding、Content-Language、 Content-Length、Content-Location、Content-MD5、Content-Range、Content-Type、 Etag、Expires、Last-Modified、extension-header。

MVP 中对 Presenter 如何进行测试

各层单元测试选型

各层由于特性不同,所采用的测试工具也不一样

JVM
JVM
  • Model层:涉及数据库操作,依赖 Android 环境,使用 AndroidJUnitRunner 测试
  • View层:涉及 UI,使用 Espresso 进行测试
  • Presenter层:不需要 Android 环境,纯 JAVA 代码,使用 JUnit 测试

MVP 的一大优点就是将数据、视图、逻辑解耦,从而可以对其中某一角色进行单独测试。然而,想要对 Presenter 进行测试,就必须回答以下几个问题:

  1. 测试过程要避免通过网络或者本地存储产生脏数据,因此必须对数据层接口进行 Mock
  2. 为了提高执行效率,并且 Presenter 本身是视图无关的,因此测试用例应该可以脱离真机/模拟器独立运行,也就是说,要有对 View 的Mock
  3. 如何检验 Presenter 的各个方法运行成功了

我们以 Demo 中对 AddEditTaskPresenter 的测试为例,看参考答案是怎样的,对应的类是AddEditTaskPresenterTest.java

1
2
@Mock
private TasksRepository mTasksRepository;

这里直接使用 Mockito 的@Mock注解来声明,需要注意的是要在测试用例运行之前通过MockitoAnnotations.initMocks(this)进行注入。如此这般解决了数据层 Mock 的问题。

对 View 的注入也一样

1
2
@Mock
private AddEditTaskContract.View mAddEditTaskView;
1
2
3
4
5
6
7
8
9
@Before
public void setupMocksAndView() {
// Mockito has a very convenient way to inject mocks by using the @Mock annotation. To
// inject the mocks in the test the initMocks method needs to be called.
MockitoAnnotations.initMocks(this); // 注入 Mock 对象

// The presenter wont't update the view unless it's active.
when(mAddEditTaskView.isActive()).thenReturn(true); // 这段代码的含义是“当 isActive 被调用时,直接返回 true”
}

接下来我们看如何检验 Presenter 里面各个方法调用成功,选一个比较复杂的涉及到回调的场景,也就是获取单个 Task,这是一个异步回调接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void populateTask_callsRepoAndUpdatesView() {
Task testTask = new Task("TITLE", "DESCRIPTION");
// Get a reference to the class under test
mAddEditTaskPresenter = new AddEditTaskPresenter(testTask.getId(),
mTasksRepository, mAddEditTaskView, true);

// When the presenter is asked to populate an existing task
mAddEditTaskPresenter.populateTask(); // 这里会调用 Repository 里面的 getTask 方法

// Then the task repository is queried and the view updated
// 验证调用到了 getTask 方法,并且将回调赋给 mGetTaskCallbackCaptor,后面可以对 captor 自由触发回调
verify(mTasksRepository).getTask(eq(testTask.getId()), mGetTaskCallbackCaptor.capture());
assertThat(mAddEditTaskPresenter.isDataMissing(), is(true)); // 这时候还没有加载成功 task

// Simulate callback
mGetTaskCallbackCaptor.getValue().onTaskLoaded(testTask);

// 验证 View 里面相应方法得到调用
verify(mAddEditTaskView).setTitle(testTask.getTitle());
verify(mAddEditTaskView).setDescription(testTask.getDescription());
assertThat(mAddEditTaskPresenter.isDataMissing(), is(false)); // 此时加载成功
}

综上,可以理解对 Presenter 进行单元测试的验证流程就是“调用 Presenter 里面的某个方法 -> 将回调暂存 -> 验证 View 里面相应的方法被执行 -> 给回调赋值,验证回调”

屏幕物理尺寸,像素,dp,px,sp

物理尺寸:这个很好理解,就是屏幕长多少mm,宽多少mm。但通常不会直接说长宽,而是用“xx英寸”表示,比如我的小米5x就是5.5英寸,这里的5.5英寸指的是对角线长度,1英寸 ≈ 2.54cm,5.5英寸 ≈ 13.97cm ≈ 14cm,市面上如今 90% 以上的手机都是 16:9 的,所以可以列出方程 (16x)^2 + (9x)^2 = 14^2,解方程得到 x = 0.76cm,得出小米5x屏幕物理尺寸为长 12.16cm,宽 6.84cm。

5x屏幕尺寸
5x屏幕尺寸

分辨率/像素:从上图里看到,5x宽高为 1080px * 1920px,对于这样的手机通常称其分辨率为 1080p。像素的概念比较容易理解,一个像素就是液晶屏的一个最小发光单元,8-bit 游戏常被称为像素游戏,因为像素颗粒通常比较大,图像有方块感。

物理尺寸与分辨率没有必然的关系。

像素密度:Pixels Per Inch,PPI,也称为 Dots Per Inch(DPI)。每英寸上排列的像素个数,这个“每英寸”是长还是宽呢?都不是,是指对角线。通过勾股定理计算,1080*1920 分辨率的屏幕,其对角线像素数为 2203p,那么小米5x的 PPI 就是 2203/5.5 = 400。像素密度越高,锯齿感越低,显示越精细。

倍率和逻辑像素:iPhone 3gs 和 4s,物理尺寸都是 3.5 英寸,3gs 的分辨率是 320x480,4s 的分辨率则是 640x960,4s 的像素密度是 3gs 的两倍,单个像素尺寸是 3gs 的一半。但是在显示中,这两个设备的显示效果却是一样的,原因在于 4s 用 2x2 个像素合并成 1 个像素,如下图。这使得同样的图片在 4s 上显示更加清晰。然而这对图片源文件有要求,必须使用带有“@2x”后缀的图片,系统会自动将其用于 4s 设备上。

3gs 4s
3gs 4s

上面讲的是苹果的处理方法,对于 Android 这并不太适用,因为 Android 设备的分辨率实在太多了。因此划分为多种尺寸,如下。

Android DPI
Android DPI

以 160DPI(mdpi) 为基准,倍率为一倍,其它密度以此计算。在 160DPI的情况下,1px = 1dp。

  • ldpi [0.75倍]
  • mdpi [1倍]
  • hdpi [1.5倍]
  • xhdpi [2倍]
  • xxhdpi [3倍]
  • xxxhdpi [4倍]

所以为了保证准确高效的沟通,无论是在标注图还是在日常沟通中,设计人员与开发人员都需要尽量以逻辑像素尺寸来描述和理解界面,真正决定显示效果的,是逻辑像素尺寸。然而并不是所有 Android 设备的逻辑像素尺寸都一致,比如两种常见的屏幕480×800和1080×1920,它们分别属于hdpi和xxhdpi。除以各自倍率1.5倍和3倍,得到逻辑像素为320×533和360×640。很显然,后者更宽更高,能显示更多内容。

px:pixels,像素,屏幕上实际的像素点单位

dp:device independent pixels, 设备独立像素,安卓专用长度单位,以160ppi屏幕为标准,则 1dp=1px。dp*ppi/160=px

sp:scaled pixels,放大像素,安卓专用字体单位,以160ppi屏幕为标准,字体大小为100%时,则1dp=1px

Android 一般以 360x640 的逻辑尺寸来设计 UI

Android常见屏幕尺寸与DPI
Android常见屏幕尺寸与DPI