前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >全民K歌折叠屏适配探索

全民K歌折叠屏适配探索

作者头像
QQ音乐技术团队
发布2021-07-12 12:40:24
2.3K0
发布2021-07-12 12:40:24
举报

| 导语随着移动端的屏幕迅猛发展,越来越多的异形屏幕诞生了;这给用户带来了全新的体验,也同时给开发者带来了适配的问题。在本篇文章中主要描述了全民K歌在折叠屏适配上的探索与常见的适配方案梳理。

现如今,移动端屏幕的发展可谓迅猛百花齐放!

在之前,我们已经适配了:不同比例、圆角、挖孔、刘海…

是时候,让我们开始适配折叠屏吧!

本篇文章主要介绍折叠屏的基础概念及几种适配方案;同时涵盖了全民K歌在折叠屏上的适配探索与实际效果。

折叠屏

折叠屏,顾名思义,其显示屏采用柔性技术,可在使用时对屏幕进行折叠、展开的操作。目前以华为、三星生产的设备为代表。

屏幕折叠主要分:内折、外折、两折、多折等不同折叠方式。当屏幕发生折叠、展开等行为时其尺寸与屏幕物理特性会发生变化,这种情况对现有的应用或多或少都会产生一些问题。

故折叠屏适配的主要目的:在应用运行时无论屏幕素质(尺寸、密度、比例、方向、装载 )如何变化,应用总能以相对合理的方式给用户展示数据信息,且保证稳定运行。

优化效果

这是全民K歌优化前后的效果对比。

优化前:

优化后(这是视频,请点击播放享用!):

对于宽比高长的视频来说:

  • 在首页(容器高宽固定)情况下,无论展开、折叠其宽度填满,高度居中自适应伸缩。
  • 在详情页(容器宽度固定、高度可变)情况下,折叠时:容器高度计算为最低高度,视频垂直居中显示;展开时:视频宽度填满、高度自适应伸缩、容器自动扩容。

对于宽比高短的视频来说:

  • 在首页(容器高宽固定)情况下,展开时视频高度填满,宽度居中对齐;视频高度填满,宽度按比例溢出。
  • 在详情页(容器宽度固定、高度可变)情况下,折叠时:容器高宽与视频等比占满屏幕宽度,高度自适应;展开时:视频尽可能放大,但满足高度在等比情况下不会溢出最大可视范围。

异常现象与系统处理

一般来说,系统在折叠屏发生改变时,会有一些自适应的处理,其可以满足一些常规应用的展示效果,但其处理方式会导致用户体验受损。

常见表现

一般来说问题的表现有2种情况:

  1. 界面销毁重建→非流畅性体验
  2. 重建后界面异常→展示体验不好

在K歌,因并未对折叠屏这种会发生屏幕素质变化的硬件进行适配,所以两种情况均存在。

界面异常重建行为

对于第一种情况往往表现为:折叠、关闭的过程中界面消失,并在一定时间后恢复,但界面重建后可能出现数据丢失。

界面展示异常情况

对于第二种情况,则是在界面重建后依然显示的不够完美。依然存在很多异常现象:

其主要在一些作品的展示上有问题,这是因为K歌在代码中针对当前的屏幕高宽在初始化逻辑中做了一些固化的高宽设置。

系统兜底

界面重建行为是系统的一种兜底的保护行为,当屏幕或设备信息发生变化时,若程序无法自行处理变更,那么最为稳妥的策略则是直接销毁对应界面,并在新的参数下重建界面即可;有些场景下我们是接受界面重建行为的,比如:设置页、关于界面。

界面重建时,不可避免的会发生数据丢失的情况,当然这往往取决于业务是否重载:onSaveInstanceState。

代码语言:javascript
复制
@Override
public void onSaveInstanceState(Bundle outState) {
    LogUtil.i(TAG, "onSaveInstanceState:" + this);
}

业务可以选择在该方法中保存界面的相关数据,并在重建后恢复相关数据即可;但,这会在一定程度上导致界面出现白屏、黑屏等情况。

除了onSaveInstanceState可以帮助界面重建还可以使用ViewModel或持久存储来保存界面的各项状态;以便能在新的Activity实例中正确的初始化布局。

但,对于首页、详情页这种较为重要的页面如果发生重建行为,一个是容易导致当前的数据丢失,第二个是容易出现黑屏、白屏时间过长,用户感受不太好。

更好的适配方案

为此,我们得采取自行处理配置变更的方案,一般来说有如下解决策略:

  1. 填充适配模式(K歌采取的方案)
  2. 多窗口模式
  3. 应用内分屏模式
  4. 兼容模式

下面,我们先来谈谈全民K歌的适配策略。

填充适配模式

开始之前,我们先了解一下 “什么是填充适配模式?”

在该模式下,应用所处屏幕发生改变时,总能以相对合理的方式重新调整布局,以便能给予用户更好的视觉体验。其效果如文章开头的手机模型展开折叠效果一致。

想要适配这样的效果,首先我们得保证界面不会被异常重建,首先设置应用支持的比例。

代码语言:javascript
复制
<meta-data
    android:name="android.max_aspect"
    android:value="2.7"/>
<meta-data
    android:name="android.min_aspect"
    android:value="1.0" />

另外,我们还需要配置Manifest文件:

代码语言:javascript
复制
<activity
    android:name="com.tencent.karaoke.module.detail.ui.DetailActivity"
    android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
    android:screenOrientation="portrait"
    android:windowSoftInputMode="adjustResize"/>

configChanges

在这里,我们主要关注:android:configChanges 配置;可以发现这里的配置与常规的有一点点的不同,主要体现在增加了:smallestScreenSize|screenLayout

为什么仅仅screenSize不行呢,下面的表格可以为你解惑:

PS:折叠屏的变化过程是屏幕的重新装载过程,其物理尺寸发生了改变,所以需要增加新的支持。一般情况下,这样配置后就足够了,但全民K歌依然有切换后的展示问题,虽然不重建了,但是界面样式依然不友好。

这是为什么呢?

系统更新流程

常规情况下,在屏幕折叠状态发生改变时,系统会强制重新发起一次从RootView到各个子View的测量、布局操作。

其API调度的顺序如下:

  1. 更新Activity的配置
  2. 更新View树的配置
  3. 更新Application的配置

其触发的逻辑代码如下:

代码语言:javascript
复制
// ViewRootImpl
public void updateConfiguration(int newDisplayId) {
    final int lastLayoutDirection = mLastConfigurationFromResources.getLayoutDirection();
    final int currentLayoutDirection = config.getLayoutDirection();
    mLastConfigurationFromResources.setTo(config);
    if (lastLayoutDirection != currentLayoutDirection
            && mViewLayoutDirectionInitial == View.LAYOUT_DIRECTION_INHERIT) {
        // 方向
        mView.setLayoutDirection(currentLayoutDirection);
    }
    // 屏幕配置变更通知
    mView.dispatchConfigurationChanged(config);
 
    // 发起一次重新测量
    requestLayout();
}

所以说,我们的整体布局若是采取的自适应+Padding+Margin的模式,一般来说就算是有问题也不会太过于难看;在界面切换时根View会自适应为屏幕的新高宽,并进行重新的测量。

视频内容适配

对于全民K歌来说,有较多的视频图片展示场景;在这些场景中往往存在着较多的适配问题。

该问题主要体现在业务场景中需要根据屏幕高宽进行固化样式的逻辑,这样的情况在其他的App来说也是经常存在的,故单独拎出来统一说明,在本节中主要涵盖了全民K歌的解决方案

全民K歌涉及到模版、视频的播放,有较多的业务场景中需要根据屏幕高宽进行固化高宽的逻辑。

基本原则

这也是导致界面显示异常问题的地方,拿详情页来说:

详情页展示视频内容的原则如下:

  1. 尽可能完整的展示视频内容(不溢出)
  2. 尽可能利用用户的屏幕(屏幕高度-顶部Bar-底部Bar-底部操作部分)
  3. 窄视频不可低于最低高度限制(保障视频区域的歌词展示与操作)

总体来说,全民K歌的视频播放区域并非是一成不变的固化高度,而是根据当前的屏幕的可容纳范围+视频的比例合理分配的一个高度给予视频显示。

这是为了极大化的发挥视频的显示效果,一份16:9的视频在低于16:9的手机上肯定不能完整显示,那么就需要裁剪部分或缩小部分视频,以便能正常显示视频的同时操作的曝光也不受到影响。

最终调整完成后,我们会强制设置外部容器以及视频显示布局的高宽,以便能达到我们所需的缩放效果。

代码语言:javascript
复制
// 视频区域调整
val videoView = mViewHolder.mPlayScene.surfaceView
val videoParams = videoView.layoutParams as FrameLayout.LayoutParams
videoParams.width = displayAdapterResult.mVideoWidth
videoParams.height = displayAdapterResult.mVideoHeight
videoView.requestLayout()
 
// 外层布局高、宽调整
mViewHolder.mPlayScene.setPlayViewHeight(
    displayAdapterResult.mLayoutHeight,
    displayAdapterResult.mLayoutWidth
)

适配问题

上述部分的原则没有问题,但实现的代码是有一定问题的。

获取屏幕高宽问题

全民K歌中有一个全局的工具类:DisplayMetricsUtil ,其主要负责全局的各项基础单位转化与获取屏幕高宽。

代码语言:javascript
复制
//屏幕宽px
private static int sScreenWidth = 0;
//屏幕高px
private static int sScreenHeight = 0;

static {
    WindowManager wm = (WindowManager) KaraokeContextBase.getApplication().getSystemService(Context.WINDOW_SERVICE);
    if (wm != null) {
        sScreenWidth = wm.getDefaultDisplay().getWidth();
        sScreenHeight = wm.getDefaultDisplay().getHeight();
    }
}

/**
 *
 * @return 屏幕宽度
 */
public static int getScreenWidth() {
    if (sScreenWidth == 0 || sScreenHeight == 0) {
        WindowManager wm = (WindowManager) KaraokeContextBase.getApplication().getSystemService(Context.WINDOW_SERVICE);
        if (wm != null) {
            sScreenWidth = wm.getDefaultDisplay().getWidth();
            sScreenHeight = wm.getDefaultDisplay().getHeight();
        }
    }
    return sScreenWidth;
}

全民K歌为了更为快速的得到当前的屏幕高宽,所以我们做了缓存,的确屏幕的基础素质一般来说并不会经常变化。

但,恰巧就是这里的缓存值影响了我们的全局计算;因为屏幕折叠状态变化,其高宽也变化了。

此时,我们依然使用工具方法去拿值,得到的是之前最开始初始化的值,而不能拿到当前真实的值,所以就算是我们在主页展开手机,然后进入详情页依然会出现上述的显示异常问题。

触发时机问题

首先,我们的触发计算高宽的时机一般有2个:

  1. 详情页数据拉取完成时进行一次计算(预先布局)
  2. 视频启播后根据视频的真实比例再进行一次计算

在之前,我们有说到屏幕出现素质改变时,其会触发View的重新测量逻辑,然后再次绘制上屏。

但问题点恰巧在这里,因为我们的逻辑是独立于测量周期外的固化逻辑;所以当触发再次测量时,当前方法并不能再次计算正确的值。

以至于,最终界面展示的视频区域样式还是前一个状态下的样子。

解决方法有很多:

  1. 添加View的测量监听;实际并不可取,因为我们不需要反复的测量视频的展示样式,避免带来界面跳变以及展示问题。
  2. 将计算逻辑内嵌到View内部测量回调中;实际并不可取,原因和第一点一致。
  3. 收到界面变化通知时,触发重新计算逻辑。

对于时机的先后顺序:View通知 → Activity/Fragment通知 → Application通知.

解决方案

在这里重新做了一个新的工具类,其满足:

  1. 依然具有缓存能力
  2. 但,缓存可失效
  3. 但,缓存有时效

基础实现

代码语言:javascript
复制
/**
 * 刷新屏幕信息,如果失效的话
 */
private static void refreshOnInvalid() {
    final long time = SystemClock.elapsedRealtime();
    final boolean isOverdue = (time - SCREEN_REFRESH_TIMESTAMP) > REFRESH_INTERVAL;
    if (isOverdue || SCREEN_WIDTH_PIXELS <= 0 || SCREEN_HEIGHT_PIXELS <= 0) {
        final DisplayMetrics displayMetrics = getCurrentResources().getDisplayMetrics();
        SCREEN_WIDTH_PIXELS = displayMetrics.widthPixels;
        SCREEN_HEIGHT_PIXELS = displayMetrics.heightPixels;
        SCREEN_REFRESH_TIMESTAMP = time;
    }
}
public static int getScreenWidth() {
    refreshOnInvalid();
    return SCREEN_WIDTH_PIXELS;
}
public static int getScreenHeight() {
    refreshOnInvalid();
    return SCREEN_HEIGHT_PIXELS;
}

可以看出我们在每次获取之前多了一个检查操作,而检查失效则会触发刷新。

另外,这里有一点改进:刷新的操作不再像之前从WindowManager获取,而是就近的从顶层的Activity的资源中获取,这有如下好处:

  1. 其耗时更低
  2. 从前面的更新逻辑来看Activity的数据会比Application更早得到,如果使用Application则会出现得到的值不正确的情况
  3. 正在显示的Activity与未显示的Activity并不相同,其值并非完全一致
  4. 正在显示的Activity内部的值始终跟随正在显示的状态,如果是分屏模式Activity拿到的是半屏的真实显示状态,而WindowManager则会拿到全局的大屏幕信息
  5. 当前View与当前界面的关系就决定了,View应该尽可能跟随自身界面所展示的信息
  6. 如果我们针对Activity做特殊的配置比如:固定倍率、固定字体比例等则可以拿到当前界面的,可定制化处理

时间失效:

这里有一个时间阈值,暂定为2s;这有就算有频繁调度也不会有问题,而超过2s的调度,则算是一次新的调度了,非频繁调度时机。

强制失效:

当我们收到系统的界面变更消息时,则会强制标记失效,下次获取操作将会刷新为最新的值。

系统通知

在屏幕素质变化时,系统会通过相关API通知到业务,其时机有:View、Activity、Fragment、Application

代码语言:javascript
复制
public class CustomView extends View {
    @Override
    protected void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
    }
}

在K歌中,选择的是Fragment,因为K歌的业务是完全以Fragment为基的;如果业务是自定义View,则在View内部获取,而如果是外部的计算逻辑则通过Fragment的通知。

代码语言:javascript
复制
// Fragment
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    DisplayUtil.notifyOnConfigurationChanged(this, newConfig);
}
 
// DisplayUtil
/**
 * 通知屏幕素质有变化
 */
public static void notifyOnConfigurationChanged(@NonNull Fragment fragment, @NonNull Configuration configuration) {
    // 使缓存失效,以便获取时得到最新的数据
    SCREEN_REFRESH_TIMESTAMP = 0L;
    final DisplayViewModel displayViewModel = ViewModelProviders.of(fragment).get(DisplayViewModel.class);
    displayViewModel.notifyOnConfigurationChanged(configuration);
}
 
/**
 * 注册屏幕素质变化监听
 */
public static void registerOnConfigurationChangeListener(@NonNull Fragment fragment, @NonNull DisplayOnConfigurationChangeListener listener) {
    final DisplayViewModel displayViewModel = ViewModelProviders.of(fragment).get(DisplayViewModel.class);
    displayViewModel.registerOnConfigurationChangeListener(fragment, listener);
}
 
/**
 * 移除屏幕素质变化监听
 */
public static void unregisterOnConfigurationChangeListener(@NonNull Fragment fragment, @NonNull DisplayOnConfigurationChangeListener listener) {
    // ...
}

那么业务可以在具体的场景中,直接注册监听,在收到消息时触发自己的测量逻辑即可。

在这里,我们使用了ViewModel作为我们的事件中转,所以业务也无需手动的remove监听。

在界面销毁或不可见时,其会自动销毁或延迟通知,这样我们仅仅顶层的界面需要触发重测,而底层的界面需要展示时再进行,这会降低一些切换状态时的计算量。

视频适配

当然视频的显示问题远远不止于此,并不是简单的将界面的高宽获取正确就可以的,我们还需要一些自动化的尺寸适配。

计算原则

我们可以根据前面提及的视频尺寸,容器的波动范围(容器高度不是固定,而是随视频可变,有范围);经过一个合理的运算,最终输出一个外部容器的尺寸,以及视频的展示尺寸。

将两者使用帧布局的方式布局到一起后,则可以直接达到合理的显示效果,其显示的模式需要保障视频的比例不被变形;而中间的计算模型应该是共用的,全局可共享的。

显示原则

且,运算后的视频比例不会变形,其与布局在帧布局中显示后等效为2种显示效果:

剩下的则是对运算模型进行细化拆分,以便能做到在各项尺寸均能有一个较为舒适的体验。

计算模型

这是K歌的视频适配策略:

整个计算模型是K歌共用的计算路径,而不同的业务场景仅仅只是决定其输入参数的不同即可;那么在计算后则会有一个较为合理的展示效果。

计算模型代码

整体代码与一次测量逻辑类似,根据输入参数进行合理的适配即可,当然业务还可以选择是否强制某个缩放模式;默认是自适应模式。

其代码如下:

代码语言:javascript
复制
private static Result measure(final DisplayAdapter parameter, final int scaleType) {
    if (parameter.mVideoAspectRatio < parameter.mLayoutMinAspectRatio
        || parameter.mVideoAspectRatio > parameter.mLayoutMaxAspectRatio) {
        // 超出调节范围,强制缩放到极限尺寸
        final int outLayoutWidth;
        final int outLayoutHeight;
        if (parameter.mVideoAspectRatio < parameter.mLayoutMinAspectRatio) {
            outLayoutWidth = parameter.mLayoutMaxWidth;
            outLayoutHeight = parameter.mLayoutMinHeight;
        } else {
            outLayoutWidth = parameter.mLayoutMinWidth;
            outLayoutHeight = parameter.mLayoutMaxHeight;
        }
 
        if (scaleType == SCALE_TYPE_CENTER_CROP) {
            // 强制CenterCrop策略
            return crop(parameter, outLayoutWidth, outLayoutHeight);
        }
 
        if (scaleType == SCALE_TYPE_FIT_CENTER) {
            // 强制FitCenter策略
            return fit(parameter, outLayoutWidth, outLayoutHeight);
        }
 
        // 当前输出的容器高宽比
        final float currentLayoutAspectRatio = outLayoutHeight / (float) outLayoutWidth;
 
        // 自适应策略
        if (currentLayoutAspectRatio > 1.5f
            && Math.abs(currentLayoutAspectRatio - parameter.mVideoAspectRatio) <= 0.23f) {
            // 容器高宽比 > 1.5(3/2)
            // 容器高宽比与视频原始高宽容错23%范围(16/9视频在20/10容器内可占满显示)
            // CenterCrop
            return crop(parameter, outLayoutWidth, outLayoutHeight);
        } else {
            // FitCenter
            return fit(parameter, outLayoutWidth, outLayoutHeight);
        }
    } else {
        // 范围内,则一定可以有一个合适的布局尺寸
        // 一定满足FIT_CENTER模式
        // 视频尺寸某一边超出区间则按比例调整到布局尺寸范围内
        // 布局尺寸等同于视频尺寸
 
        int outWidth = parameter.mVideoWidth;
        int outHeight = parameter.mVideoHeight;
 
        if (parameter.mVideoAspectRatio > 1f) {
            // 视频偏高
            // 调节宽度,高度按比例自适应
            final int expectVideoWidth = Math.min(parameter.mLayoutMaxWidth, Math.max(parameter.mLayoutMinWidth, outWidth));
            if (outWidth != expectVideoWidth) {
                outWidth = expectVideoWidth;
                outHeight = (int) (outWidth * parameter.mVideoAspectRatio);
            }
        } else {
            // 视频偏宽或等高宽
            // 调节高度,宽度按比例自适应
            final int expectVideoHeight = Math.min(parameter.mLayoutMaxHeight, Math.max(parameter.mLayoutMinHeight, outHeight));
            if (outHeight != expectVideoHeight) {
                outHeight = expectVideoHeight;
                outWidth = (int) (outHeight / parameter.mVideoAspectRatio);
            }
        }
 
        return fill(outWidth, outHeight, outWidth, outHeight);
    }
}

输出参数代码

代码语言:javascript
复制
/**
 * 视频显示样式计算出参
 */
public static class Result {
    public final int mVideoWidth;
    public final int mVideoHeight;
    public final int mLayoutWidth;
    public final int mLayoutHeight;
 
    /**
     * 当视频控件设置为 Layout 尺寸时,需设置其Matrix值
     *
     * @param clipDirectionForCenterCrop 视频Crop模式下裁剪方向
     * @return Matrix
     */
    @NonNull
    public Matrix getVideoMatrix(int clipDirectionForCenterCrop) {
        final Matrix matrix = new Matrix();
        if (mVideoWidth == mLayoutWidth && mVideoHeight == mLayoutHeight) {
            return matrix;
        }
 
        // 缩放部分
        final float scaleX = mVideoWidth / (float) mLayoutWidth;
        final float scaleY = mVideoHeight / (float) mLayoutHeight;
        matrix.setScale(scaleX, scaleY);
 
        // 位移部分
        final float dx, dy;
 
        // 是否水平居中剪裁
        final boolean clipCenterHorizontal = (clipDirectionForCenterCrop & CLIP_DIRECTION_CENTER_HORIZONTAL) == CLIP_DIRECTION_CENTER_HORIZONTAL;
        // 水平剪裁
        if (mVideoWidth <= mLayoutWidth || clipCenterHorizontal) {
            // 视频未溢出 或 居中裁剪
            // 居中裁剪
            dx = (mLayoutWidth - mVideoWidth) * 0.5f;
        } else {
            if ((clipDirectionForCenterCrop & CLIP_DIRECTION_LEFT) == CLIP_DIRECTION_LEFT) {
                // 裁剪左边
                dx = mLayoutWidth - mVideoWidth;
            } else {
                // 不做位移,裁剪右边
                dx = 0f;
            }
        }
 
        // 是否垂直居中剪裁
        final boolean clipCenterVertical = (clipDirectionForCenterCrop & CLIP_DIRECTION_CENTER_VERTICAL) == CLIP_DIRECTION_CENTER_VERTICAL;
        // 垂直剪裁
        if (mVideoHeight <= mLayoutHeight || clipCenterVertical) {
            // 视频未溢出 或 居中裁剪
            // 居中裁剪
            dy = (mLayoutHeight - mVideoHeight) * 0.5f;
        } else {
            if ((clipDirectionForCenterCrop & CLIP_DIRECTION_TOP) == CLIP_DIRECTION_TOP) {
                // 裁剪顶部
                dy = mLayoutHeight - mVideoHeight;
            } else {
                // 不做位移,裁剪右边
                dy = 0f;
            }
        }
 
        matrix.postTranslate(Math.round(dx), Math.round(dy));
 
        return matrix;
    }
}

在其中还有一个getVideoMatrix的方法,该方法用于视频布局区域仅仅只有一个视频View,视频View充当了容器的角色,而视频实际显示的内容的缩放使用Matrix进行设置的模式。

至此,全民K歌的适配完成!

更多界面适配方案

我们主要来谈谈后3种适配方案。

  1. 填充适配模式(K歌采取的方案)
  2. 多窗口模式
  3. 应用内分屏模式
  4. 兼容模式

多窗口模式

Android 7.0 新增了对同时显示多个应用窗口的支持。在手持设备上,两个应用可以在分屏模式下左右并排或上下并排显示。在电视设备上,应用可以使用画中画模式,在用户与另一个应用互动的同时继续播放视频。

这样的适配基本合理,但也并不完美,最大的问题是当屏幕展开时,当前应用会自动缩放到一半大小,另一半可以选择一个新的应用打开;这无异于将用户的注意力分散开了,可能会导致自身应用的用户使用时长下降。

另外,9.0以下设备在该模式下仅有一个应用会处于焦点状态下,而另外的应用则会处于暂停运行状态,这也会导致界面实际可见,但生命周期受到影响,从而影响统计数据。

在9.0引入了Multi-resume的新特性,旨在解决该问题,让所有可见的应用均保持在活跃状态,现在也在持续优化中。

如果使用这样模式,我们需要在menifest 中的 Application 或对应的 Activity 下声明:

代码语言:javascript
复制
<application
    android:resizeableActivity="true">

PS:这个参数在 Android 7.0 或更高版本默认为 true,以下则默认为 false。如果不需要分屏适配则需要显示设置为false。

更多信息可查阅:https://developer.android.com/guide/topics/ui/multi-window

应用内分屏模式

既然分屏模式有一些缺陷,那么我们更为希望的是应用能够尽可能多的占用屏幕的有效空间,且能够有较好的视觉效果;华为提供平行视界方案,实现Activity为基础的应用内分屏显示,简化应用适配。

简单来说,其效果还是如分屏模式的效果类似,但同一屏幕的两半展示的均为同一应用的不同Activity。

当然,就其适配来说相对比较麻烦,需要梳理当前App的各个页面的职责,并将其进行合理的分配后进行适配,这会给适配带来很大的工作量,且目前该方案为华为的私有方案,并不能完美支持其他厂商的设备。

如图,我们需要管理多个Activity的生命周期,交互逻辑等。

更多信息可查阅:

https://developer.huawei.com/consumer/cn/doc/90101#:~:text=3.3-,%E5%BA%94%E7%94%A8%E5%86%85%E5%88%86%E5%B1%8F,-%E4%B8%BA%E4%BA%86%E6%9B%B4%E6%9C%89%E6%95%88

兼容模式

兼容模式很好理解,就是系统发现APP并不能完美适配对应屏幕时采取的一种保守策略,使用就近比例直接内嵌显示。

首先我们需要确保:resizeableActivity=false,并设置好对应的支持比例范围:

8.0 以下版本:

代码语言:javascript
复制
<meta-data
    android:name="android.max_aspect"
    android:value="2.4"/>
<meta-data
    android:name="android.min_aspect"
    android:value="1.8" />

8.0 以上版本:

代码语言:javascript
复制
<activity android:name=".MainActivity"
          android:maxAspectRatio="2.4" />

那么,此时的界面展示可能会出现如下的情况:

一个应用在不合适的比例设备上以内嵌且保持极端比例模式展示。

当然,该模式下会引发界面的重建行为,也就是切换比例时会出现白屏、黑屏情况。如果同时加上前面的android:configChanges适配,则可一定程度上避免重建行为。

扩展适配-多显示屏

对于折叠屏来说,往往并不仅仅是一张屏幕的展开与折叠操作(屏幕的部分显示与全部显示的区别),还有一张屏幕切换到另外一张屏幕的情况。

另外:华为手机外接hdmi到显示器后的状态也算是多显示器的应用,不过其采取的是兼容模式运行。

Android 10 (API 级别 29) 或更高版本 支持辅助显示屏上的 Activity。

如果 Activity 在具有多个显示屏的设备上运行,则用户可以将 Activity 从一个显示屏移到另一个显示屏;多个 Activity 可以同时接收用户输入。

获取显示器列表

我们可以通过 DisplayManager 系统服务获取可用显示屏:

代码语言:javascript
复制
DisplayManager mgr = (DisplayManager) this.getBaseContext().getSystemService(Context.DISPLAY_SERVICE);
Display[] displays = mgr.getDisplays();

判断显示能力

拿到显示器列表后,我们可以通过显示器的信息检查显示器是否是我们所需的显示器。

代码语言:javascript
复制
Display display = displays[i];
int displayId = display.getDisplayId();
Point point = new Point();
display.getSize(point);
 
if (point.y >= 高度 && point.x >= 宽度) {
    Intent intent = new Intent(...);
    intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK or Intent.FLAG_ACTIVITY_NEW_TASK);
    // 检查是否允许启动
    boolean allow = activityManager.isActivityStartAllowedOnDisplay(context, displayId, intent);
}

指定显示屏显示

如果通过,且确定要在特定显示屏上显示,则需要给启动流程携带显示屏Id参数。

代码语言:javascript
复制
val options = ActivityOptions.makeBasic()
options.launchDisplayId = targetDisplay.displayId
startActivity(intent, options.toBundle())

如何测试适配效果

首选,当然是找一台折叠屏设备,真枪实操即可;当然很多时候我们并没有对应的设备,那么我们可以去官方开发者中查找到折叠前后的分辨率;然后通过ADB或模拟器的方式来模拟是可以的。

ADB

如果你没有折叠设备,则可以使用adb命令进行强制模拟这个过程:

代码语言:javascript
复制
# 折叠切展开模拟方法:
#(1)预先将手机设置主屏分辨率:
adb shell wm size 1148x2480
#(2)通过修改手机分辨率为全屏分辨率模拟状态切换:
adb shell wm size 2200x2480
# 展开切折叠模拟方法:
#(1)预先将手机设置全屏分辨率:
adb shell wm size 2200x2480
#(2)通过修改手机分辨率为主屏分辨率模拟状态切换:
adb shell wm size 1148x2480
# 分辨率恢复方法:
adb shell wm size reset

PS:一般来说,展开折叠过程主要是屏幕卸载与装载的过程(对于应用来说,此时是系统的内置屏幕发生了改变),而非简单的分辨率调整过程。

模拟器

另外还有一种方法则是使用模拟器,模拟折叠的情况。

在Android Studio 3.5版本之后均可新建模拟器。

总结‍

可以看出Google也在每次的版本更新中不断对不同的屏幕进行适配,从刘海、挖孔调整顶部状态栏高度、安全区域,再到折叠屏、多显示屏、多应用分屏等不同场景。

不过,有些时候厂家的想法也会比Google更早一些,这也算是相互督促的过程。

屏幕的发展还在继续,但对于应用开发而言,虽然应该保持追求完美的态度,但也不能捡了芝麻丢了西瓜;还是要根据实际的用户群体,操作场景进行合理的抉择与适配。

感谢阅读!!

参考文献

https://developer.android.com/guide/topics/ui/foldables?hl=zh-cn

https://developer.huawei.com/consumer/cn/doc/90101

https://developer.samsung.com/galaxy-z/overview.html#app-continuity

https://support-cn.samsung.com/Upload/DeveloperChina/DeveloperChinaFile/201901311831092571AA9CBD915.pdf

QQ音乐招聘Android/ios客户端开发,点击左下方“查看原文”投递简历~

也可将简历发送至邮箱:tmezp@tencent.com

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-07-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯音乐技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • | 导语随着移动端的屏幕迅猛发展,越来越多的异形屏幕诞生了;这给用户带来了全新的体验,也同时给开发者带来了适配的问题。在本篇文章中主要描述了全民K歌在折叠屏适配上的探索与常见的适配方案梳理。
  • 折叠屏
  • 优化效果
  • 异常现象与系统处理
    • 常见表现
      • 界面异常重建行为
        • 界面展示异常情况
          • 系统兜底
          • 更好的适配方案
          • 填充适配模式
            • configChanges
            • 系统更新流程
            • 视频内容适配
              • 基本原则
                • 适配问题
                  • 获取屏幕高宽问题
                  • 触发时机问题
                • 解决方案
                  • 基础实现
                  • 系统通知
                • 视频适配
                  • 计算原则
                  • 显示原则
                  • 计算模型
                  • 计算模型代码
              • 更多界面适配方案
                • 多窗口模式
                  • 应用内分屏模式
                    • 兼容模式
                      • 扩展适配-多显示屏
                        • 获取显示器列表
                        • 判断显示能力
                        • 指定显示屏显示
                    • 如何测试适配效果
                      • ADB
                        • 模拟器
                        • 总结‍
                        相关产品与服务
                        容器服务
                        腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档