注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Android 换种方式实现ViewPager

一、可行性分析 ViewPager 是一款相对成熟的 Pager 切换 View,能够实现各种优秀的页面效果,也有不少问题,比如频繁会 requestLayout,另外的话如果是加载到 ListView 或者 RecyclerView 非固定头部,会偶现白屏或...
继续阅读 »

一、可行性分析


ViewPager 是一款相对成熟的 Pager 切换 View,能够实现各种优秀的页面效果,也有不少问题,比如频繁会 requestLayout,另外的话如果是加载到 ListView 或者 RecyclerView 非固定头部,会偶现白屏或者 drawble 状态无法更新,还有就是 fragment 数量无法更新,需要重写 FragmentPagerAdapter 才行。


使用 RecyclerView 相对 ViewPager 来说,会避免很多问题,比如如果是轮播组件 View 可以复用而且会避免白屏问题,当然今天我们使用 RecyclerView 代替 ViewPager 虽然也没有实现复用,但并不影响和 ViewPager 同样的体验。



二、代码实现


具体原理是我们在 RecyclerView.Adapter 的如下两个方法中实现 fragment 的 detach 和 attach,这样可以保证 Fragment 的生命周期得到准确执行。


onViewAttachedToWindow

onViewDetachedFromWindow

FragmentPagerAdapter 源码如下(核心代码),另外需要指明的一点是我们使用 PagerSnapHelper 来辅助页面滑动:


public abstract class FragmentPagerAdapter extends RecyclerView.Adapter<FragmentViewHolder> {

private static final String TAG = "FragmentPagerAdapter";

private final FragmentManager mFragmentManager;

private Fragment mCurrentPrimaryItem = null;
private PagerSnapHelper snapHelper;

private RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState != RecyclerView.SCROLL_STATE_IDLE) return;
if (snapHelper == null) return;
View snapView = snapHelper.findSnapView(recyclerView.getLayoutManager());
if (snapView == null) return;
FragmentViewHolder holder = (FragmentViewHolder) recyclerView.getChildViewHolder(snapView);
setPrimaryItem(holder.getHelper().getFragment());

}

@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
}
};

public FragmentPagerAdapter(FragmentManager fm) {
this.mFragmentManager = fm;

}

@Override
public FragmentViewHolder onCreateViewHolder(ViewGr0up parent, int position) {
RecyclerView recyclerView = (RecyclerView) parent;

if (snapHelper == null) {
snapHelper = new PagerSnapHelper();
recyclerView.addOnScrollListener(onScrollListener);
snapHelper.attachToRecyclerView(recyclerView);
}

FragmentHelper host = new FragmentHelper(recyclerView, getItemViewType(position));
return new FragmentViewHolder(host);
}

@Override
public void onBindViewHolder(FragmentViewHolder holder, int position) {
holder.getHelper().updateFragment();

}


public abstract Fragment getFragment(int viewType);

@Override
public abstract int getItemViewType(int position);


public Fragment instantiateItem(FragmentHelper host, int position, int fragmentType) {

FragmentTransaction transaction = host.beginTransaction(mFragmentManager);

final long itemId = getItemId(position);

String name = makeFragmentName(host.getContainerId(), itemId, fragmentType);
Fragment fragment = mFragmentManager.findFragmentByTag(name);
if (fragment != null) {
if (BuildConfig.DEBUG) Log.v(TAG, "Attaching item #" + itemId + ": f=" + fragment);
transaction.attach(fragment);
} else {
fragment = getFragment(fragmentType);
if (BuildConfig.DEBUG) Log.v(TAG, "Adding item #" + itemId + ": f=" + fragment);
transaction.add(host.getContainerId(), fragment,
makeFragmentName(host.getContainerId(), itemId, fragmentType));
}
if (fragment != mCurrentPrimaryItem) {
fragment.setMenuVisibility(false);
fragment.setUserVisibleHint(false);
}

return fragment;
}


@Override
public abstract long getItemId(int position);

@SuppressWarnings("ReferenceEquality")
public void setPrimaryItem(Fragment fragment) {
if (fragment != mCurrentPrimaryItem) {
if (mCurrentPrimaryItem != null) {
mCurrentPrimaryItem.setMenuVisibility(false);
mCurrentPrimaryItem.setUserVisibleHint(false);
}
if (fragment != null) {
fragment.setMenuVisibility(true);
fragment.setUserVisibleHint(true);
}
mCurrentPrimaryItem = fragment;
}
}

private static String makeFragmentName(int viewId, long id, int fragmentType) {
return "android:recyclerview:fragment:" + viewId + ":" + id + ":" + fragmentType;
}

@Override
public void onViewAttachedToWindow(FragmentViewHolder holder) {
super.onViewAttachedToWindow(holder);
FragmentHelper host = holder.getHelper();
Fragment fragment = instantiateItem(holder.getHelper(), holder.getAdapterPosition(), getItemViewType(holder.getAdapterPosition()));
host.setFragment(fragment);
host.finishUpdate();
if (BuildConfig.DEBUG) {
Log.d("Fragment", holder.getHelper().getFragment().getTag() + " attach");
}
}


@Override
public void onViewDetachedFromWindow(FragmentViewHolder holder) {
super.onViewDetachedFromWindow(holder);
destroyItem(holder.getHelper(), holder.getAdapterPosition());
holder.getHelper().finishUpdate();

if (BuildConfig.DEBUG) {
Log.d("Fragment", holder.getHelper().getFragment().getTag() + " detach");
}
}

public void destroyItem(FragmentHelper host, int position) {
FragmentTransaction transaction = host.beginTransaction(mFragmentManager);

if (BuildConfig.DEBUG) Log.v(TAG, "Detaching item #" + getItemId(position) + ": f=" + host.getFragment()
+ " v=" + ((Fragment) host.getFragment()).getView());
transaction.detach((Fragment) host.getFragment());
}

}

ViewHolder 源码,本类的主要作用是给 FragmentManager 打桩,其次还有个作用是连接 FragmentHelper(负责 Fragment 的事务)


public class FragmentViewHolder extends RecyclerView.ViewHolder {

private FragmentHelper mHelper;

public FragmentViewHolder(FragmentHelper host) {
super(host.getFragmentView());
this.mHelper = host;
}

public FragmentHelper getHelper() {
return mHelper;
}
}

FragmentHelper 源码


public class FragmentHelper {

private final int id;
private final Context context;
private Fragment fragment;
private ViewGr0up containerView;
private FragmentTransaction fragmentTransaction;

public FragmentHelper(RecyclerView recyclerView, int fragmentType) {
this.id = recyclerView.getId() + fragmentType + 1;
// 本id依赖于fragment,因此为防止fragmentManager将RecyclerView视为容器,直接将View加载到RecyclerView中,这种View缺少VewHolder,会出现空指针问题,这里加1
Activity activity = getRealActivity(recyclerView.getContext());
this.id = getUniqueFakeId(activity,this.id);

this.context = recyclerView.getContext();
this.containerView = buildDefualtContainer(this.context,this.id);
}

public FragmentHelper(RecyclerView recyclerView,int layoutId, int fragmentType) {

this.context = recyclerView.getContext();
this.containerView = (ViewGr0up) LayoutInflater.from( this.context).inflate(layoutId,recyclerView,false);
Activity activity = getRealActivity(recyclerView.getContext());
this.id = getUniqueFakeId(activity,this.id);

this.containerView.setId(id);
// 本id依赖于fragment,因此为防止fragmentManager多次复用同一个view,这里加1
}


private int getUniqueFakeId(Activity activity, int id) {
if(activity==null){
return id;
}
int newId = id;
do{
View v = activity.findViewById(id);
if(v!=null){
newId += 1;
continue;
}
newId = id;
break;
}while (true);
return newId;
}


public void setFragment(Fragment fragment) {
this.fragment = fragment;
}

public View getFragmentView() {

return containerView;
}

private static ViewGr0up buildDefualtContainer(Context context,int id) {
FrameLayout frameLayout = new FrameLayout(context);
RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, ViewGr0up.LayoutParams.MATCH_PARENT);
frameLayout.setLayoutParams(lp);
frameLayout.setId(id);
return frameLayout;
}

public int getContainerId() {
return id;
}

public void updateFragment() {

}

public Fragment getFragment() {
return fragment;
}

public void finishUpdate() {
if (fragmentTransaction != null) {
fragmentTransaction.commitNowAllowingStateLoss();
fragmentTransaction = null;
}
}

public FragmentTransaction beginTransaction(FragmentManager fragmentManager) {
if (this.fragmentTransaction == null) {
this.fragmentTransaction = fragmentManager.beginTransaction();
}
return this.fragmentTransaction;
}
}

以上提供了一个非常完美的 FragmentPagerAdapter,来支持 RecyclerView 加载 Fragment


三、新问题


在 Fragment 使用 RecyclerView 列表时会出现如下问题


1、交互不准确,比如垂直滑动会变成 Pager 滑动效果


2、页面 fling 效果出现闪动


3、事件冲突,导致滑动不了


因此为了解决上述问题,进行了一下规避


public class RecyclerPager extends RecyclerView {

private final DisplayMetrics mDisplayMetrics;
private int pageTouchSlop = 0;
float startX = 0;
float startY = 0;
boolean canHorizontalSlide = false;

public RecyclerPager(Context context) {
this(context, null);
}

public RecyclerPager(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public RecyclerPager(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
pageTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop();
mDisplayMetrics = getResources().getDisplayMetrics();

}

private int captureMoveAction = 0;
private int captureMoveCounter = 0;

@Override
public boolean dispatchTouchEvent(MotionEvent e) {

switch (e.getAction()) {
case MotionEvent.ACTION_DOWN:
startX = e.getX();
startY = e.getY();
canHorizontalSlide = false;
captureMoveCounter = 0;
Log.w("onTouchEvent_Pager", "down startY=" + startY + ",startX=" + startX);
break;
case MotionEvent.ACTION_MOVE:
float currentX = e.getX();
float currentY = e.getY();
float dx = currentX - startX;
float dy = currentY - startY;

if (!canHorizontalSlide && Math.abs(dy) > Math.abs(dx)) {
startX = currentX;
startY = currentY;
if (tryCaptureMoveAction(e)) {
canHorizontalSlide = false;
return true;
}
break;
}

if (Math.abs(dx) > pageTouchSlop && canScrollHorizontally((int) -dx)) {
canHorizontalSlide = true;
}

//这里取相反数,滑动方向与滚动方向是相反的

Log.d("onTouchEvent_Pager", "move dx=" + dx +",dy="+dy+ ",currentX=" + currentX+",currentY="+currentY + ",canHorizontalSlide=" + canHorizontalSlide);
if (canHorizontalSlide) {
startX = currentX;
startY = currentY;

if (captureMoveAction == MotionEvent.ACTION_MOVE) {
return super.dispatchTouchEvent(e);

}
if (tryCaptureMoveAction(e)) {
canHorizontalSlide = false;
return true;
}

}
break;
}

return super.dispatchTouchEvent(e);
}

/**
* 尝试捕获事件,防止事件后被父/子View主动捕获后无法改变捕获状态,简单的说就是没有cancel掉事件
*
* @param e 当前事件
* @return 返回ture表示发送了cancel->down事件
*/

private boolean tryCaptureMoveAction(MotionEvent e) {

if (captureMoveAction == MotionEvent.ACTION_MOVE) {
return false;
}
captureMoveCounter++;

if (captureMoveCounter != 2) {
return false;
}
MotionEvent eventDownMask = MotionEvent.obtain(e);
eventDownMask.setAction(MotionEvent.ACTION_DOWN);
Log.d("onTouchEvent_Pager", "事件转换");
super.dispatchTouchEvent(eventDownMask);

return true;

}

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
super.onInterceptTouchEvent(e); //该逻辑需要保留,因为recyclerView有自身事件处理
captureMoveAction = e.getAction();

switch (e.getActionMasked()) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
canHorizontalSlide = false;//不要拦截该类事件
break;

}
if (canHorizontalSlide) {
return true;
}
return false;
}

@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow, int type) {
consumed[1] = dy;
return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
}

@Override
public int getMinFlingVelocity() {
return (int) (super.getMinFlingVelocity() * mDisplayMetrics.density);
}

@Override
public int getMaxFlingVelocity() {
return (int) (super.getMaxFlingVelocity()* mDisplayMetrics.density);
}

@Override
public boolean fling(int velocityX, int velocityY) {
velocityX = (int) (velocityX / mDisplayMetrics.scaledDensity);
return super.fling(velocityX, velocityY);
}
}

四、使用


创建一个 fragment


    @SuppressLint("ValidFragment")
public static class TestFragment extends Fragment{

private final int color;
private String name;

private int[] colors = {
0xffDC143C,
0xff66CDAA,
0xffDEB887,
Color.RED,
Color.BLACK,
Color.CYAN,
Color.GRAY
};
public TestFragment(int viewType) {
this.name = "id#"+viewType;
this.color = colors[viewType%colors.length];
}

@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGr0up container, @Nullable Bundle savedInstanceState) {

View convertView = inflater.inflate(R.layout.test_fragment, container, false);
TextView textView = convertView.findViewById(R.id.text);
textView.setText("fagment: "+name);
convertView.setBackgroundColor(color);

if(BuildConfig.DEBUG){
Log.d("Fragment","onCreateView "+name);
}
return convertView;

}


@Override
public void onResume() {
super.onResume();

if(BuildConfig.DEBUG){
Log.d("Fragment","onResume");
}
}

@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
Log.d("Fragment","setUserVisibleHint"+name);
}

@Override
public void onDestroyView() {
super.onDestroyView();

if(BuildConfig.DEBUG){
Log.d("Fragment","onDestroyView" +name);
}
}
}

接着我们实现 FragmentPagerAdapter


 public static class MyFragmentPagerAdapter extends FragmentPagerAdapter{

public MyFragmentPagerAdapter(FragmentManager fm) {
super(fm);
}

@Override
public Fragment getFragment(int viewType) {
return new TestFragment(viewType);
}

@Override
public int getItemViewType(int position) {
return position;
}

@Override
public long getItemId(int position) {
return position;
}

@Override
public int getItemCount() {
return 3;
}
}

下面设置 Adapter


 RecyclerView recyclerPagerView = findViewById(R.id.loopviews);
recyclerPagerView.setLayoutManager(new
LinearLayoutManager(this,LinearLayoutManager.HORIZONTAL,false));
recyclerPagerView.setAdapter(new MyFragmentPagerAdapter(getSupportFragmentManager()));

五、总结


整个过程轻松而愉快,当然本篇主要学习的是RcyclerView事件冲突的解决,突发奇想然后就写了个轮子,看样子是没什么大问题。


作者:时光少年
来源:juejin.cn/post/7307887970664595456
收起阅读 »

鸿蒙 Ark ui 视频播放组件 我不允许你不会

前言: 各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章 概述 在手机、平板或是智慧屏这些终端设备上,媒...
继续阅读 »

前言:


各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章


概述


在手机、平板或是智慧屏这些终端设备上,媒体功能可以算作是我们最常用的场景之一。无论是实现音频的播放、录制、采集,还是视频的播放、切换、循环,亦或是相机的预览、拍照等功能,媒体组件都是必不可少的。以视频功能为例,在应用开发过程中,我们需要通过ArkUI提供的Video组件为应用增加基础的视频播放功能。借助Video组件,我们可以实现视频的播放功能并控制其播放状态。常见的视频播放场景包括观看网络上的较为流行的短视频,也包括查看我们存储在本地的视频内容


效果图


image.png


image.png


具体实现:




  • 1 添加网络权限




在module.json5 里面添加网络访问权限


"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]

image.png
如果你是播放本地视频那么可以不添加这个 为了严谨我这边就提一下


我们要播放视频需要用到 video 组件


video 组件里面参数说明


参数名参数类型必填
srcstringResource
currentProgressRatenumberstringPlaybackSpeed8+
previewUristringPixelMap8+Resource
controllerVideoController
其他属性说明 :
.muted(false) //是否静音。默认值:false
.controls(true)//不显示控制栏
.autoPlay(false) // 手动点击播放
.loop(false) // 关闭循环播放
.objectFit(ImageFit.Cover) //设置视频显示模式。默认值:Cover

具体代码


@Entry
@Component
struct Index {


@Styles
customMargin() {
.margin({ left: 20, right: 20 })
}

@State message: string = 'Hello World'
private controller: VideoController = new VideoController();
build() {
Row() {
Column() {
Video({
src: $rawfile('video1.mp4'),
previewUri: $r('app.media.image3'),
controller: this.controller
})
.muted(false) //是否静音。默认值:false
.controls(true)//不显示控制栏
.autoPlay(false) // 手动点击播放
.loop(false) // 关闭循环播放
.objectFit(ImageFit.Cover) //设置视频显示模式。默认值:Cover
.customMargin()// 样式
.height(200) // 高度
}
.width('100%')
}
.height('100%')
}
}

最后总结


鸿蒙的视频播放和安卓还有iOS .里面差不多都有现成的组件使用, 但是底层还是有ffmpeg 的支持。 我们作为上层开发者只需要熟练掌握api使用即可做出来 一个实用的播放器 app, 还有很多细节 由于篇幅有限我就展开讲了 我们下一期再见 最后呢 希望我都文章能帮助到各位同学工作和学习 如果你觉得文章还不错麻烦给我三连 关注点赞和转发 谢谢


作者:坚果派_xq9527
来源:juejin.cn/post/7308620787329105971
收起阅读 »

前几天有个雏鹰问我,说怎么创建Menu???

这个很简单了哈,直接上代码算了 自己在这个路径下面创建一个这个的这个这个这个,很直观吧 <?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.a...
继续阅读 »

这个很简单了哈,直接上代码算了
自己在这个路径下面创建一个这个的这个这个这个,很直观吧


截屏2023-11-29 22.13.12.png


<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<item
android:id="@+id/list_view"
android:title="@string/listview">

<menu>
<item
android:id="@+id/list_view_vertical_only"
android:title="垂直标准"
tools:ignore="DuplicateIds" />

<item
android:id="@+id/list_view_vertical_reverse"
android:title="垂直反向" />

<item
android:id="@+id/list_view_horizontal_only"
android:title="水平标准" />

<item
android:id="@+id/list_view_horizontal_reverse"
android:title="水平反转" />

</menu>
</item>
</menu>

然后读取目录路面的条目的时候有一个过滤器,把你自己添加的目录放进来,点击事件也帮你写好了,里面想怎么整自己搞,


@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu, menu);
return super.onCreateOptionsMenu(menu);
}

@SuppressLint("NonConstantResourceId")
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int itemId = item.getItemId();
if (itemId != 0)
switch (itemId){
case R.id.list_view:
break;
case R.id.list_view_vertical_only:
break;
case R.id.list_view_vertical_reverse:
break;
case R.id.list_view_horizontal_only:
break;
case R.id.list_view_horizontal_reverse:
break;
}
return super.onOptionsItemSelected(item);
}

结束结束,希望下次雏鹰可以自己看,或者自己搜下,很简单的东西


作者:贾炬山
来源:juejin.cn/post/7306706954678763556
收起阅读 »

Android APP合规检查,你可能需要这个工具~

虽迟但到,这是一个通过拦截Java方法调用用以检测应用是否合规的工具,如果你的APP正饱受监管部门或应用市场时不时下发整改通知的折磨,那么用它来检查你的代码以及引用的三方库是再好不过的选择了! 如何引入 Step 1. 添加 mavenCentral all...
继续阅读 »

logo.png


虽迟但到,这是一个通过拦截Java方法调用用以检测应用是否合规的工具,如果你的APP正饱受监管部门或应用市场时不时下发整改通知的折磨,那么用它来检查你的代码以及引用的三方库是再好不过的选择了!


如何引入


Step 1. 添加 mavenCentral


allprojects {
repositories {
...
mavenCentral()
}
}

Step 2. 添加 Gradle 依赖


dependencies {
...
implementation 'io.github.loper7:miit-rule-checker:0.1.1'
}

如何使用


检查APP内是否存在不合规的方法调用



检查MIITRuleChecker内置的不合规的方法,具体可见下方方法列表



 MIITRuleChecker.checkDefaults()


如果内置的方法不满足当前需求,可自定义方法添加到list中进行检查;

比如新增一个 MainActivity 的 onCreate 方法的调用检查;



val list = MIITMethods.getDefaultMethods()
list.add(MainActivity::class.java.getDeclaredMethod("onCreate" , Bundle::class.java)) MIITRuleChecker.check(list)

当然,如果你想检查多个内置方法外的方法,只需要创建一个新的集合,往集合里放你想检查的方法member,然后传入 MIITRuleChecker.check()内即可。


log打印如下所示:


method_androidid.png


检查指定方法调用并查看调用栈堆


//查看 WifiInfo classgetMacAddress 的调用栈堆
MIITRuleChecker.check(MIITMethods.WifiInfo.getMacAddress)

log打印如下所示:


method_macaddress.png


检查一定时间内指定方法调用次数统计


//多个方法统计 (deadline 为从方法调用开始到多少毫秒后截至统计)
val list = mutableListOf().apply {
add(MIITMethods.LocationManager.getLastKnownLocation)
add(MIITMethods.LocationManager.requestLocationUpdates)
add(MIITMethods.Secure.getString)
}
MIITMethodCountChecker.startCount(list , 20 * 1000)

//单个方法统计(deadline 为从方法调用开始到多少毫秒后截至统计)
MIITMethodCountChecker.startCount(MIITMethods.LocationManager.getLastKnownLocation , deadline = 20 * 1000)

log打印如下所示:


log_count.png


检查完成并完成整改后务必移除方法 miit-rule-checker 库内的所有方法调用,将库一起移除最好


内置方法表


内置常量对应的系统方法备注
MIITMethods.WifiInfo.getMacAddressandroid.net.wifi.WifiInfo.getMacAddress()获取MAC地址
MIITMethods.WifiInfo.getIpAddressandroid.net.wifi.WifiInfo.getIpAddress()获取IP地址
MIITMethods.LocationManager.getLastKnownLocationandroid.location.LocationManager.getLastKnownLocation(String)获取上次定位的地址
MIITMethods.LocationManager.requestLocationUpdatesandroid.location.LocationManager.requestLocationUpdates(String,Long,Float,LocationListener)
MIITMethods.NetworkInterface.getHardwareAddressjava.net.NetworkInterface.getHardwareAddress()获取主机地址
MIITMethods.ApplicationPackageManager.getInstalledPackagesandroid.app.ApplicationPackageManager.getInstalledPackages(Int)获取已安装的应用
MIITMethods.ApplicationPackageManager.getInstalledApplicationsandroid.app.ApplicationPackageManager.getInstalledApplications(Int)获取已安装的应用
MIITMethods.ApplicationPackageManager.getInstallerPackageNameandroid.app.ApplicationPackageManager.getInstallerPackageName(String)获取应用安装来源
MIITMethods.ApplicationPackageManager.getPackageInfoandroid.app.ApplicationPackageManager.getPackageInfo(String,Int)获取应用信息
MIITMethods.PackageManager.getInstalledPackagesandroid.content.pm.PackageManager.getInstalledPackages(Int)获取已安装的应用
MIITMethods.PackageManager.getInstalledApplicationsandroid.content.pm.PackageManager.getInstalledApplications(Int)获取已安装的应用
MIITMethods.PackageManager.getInstallerPackageNameandroid.content.pm.PackageManager.getInstallerPackageName(String)获取应用安装来源
MIITMethods.PackageManager.getPackageInfoandroid.content.pm.PackageManager.getPackageInfo(String,Int)获取应用信息
MIITMethods.PackageManager.getPackageInfo1android.content.pm.PackageManager.getPackageInfo(String,PackageInfoFlags)获取应用信息(版本号大于33)
MIITMethods.PackageManager.getPackageInfo2android.content.pm.PackageManager.getPackageInfo(VersionedPackage,Int)获取应用信息(版本号大于26)
MIITMethods.PackageManager.getPackageInfo3android.content.pm.PackageManager.getPackageInfo(VersionedPackage,PackageInfoFlags)获取应用信息(版本号大于33)
MIITMethods.Secure.getStringandroid.provider.Settings.Secure.getString(ContentResolver,String)获取androidId
MIITMethods.TelephonyManager.getDeviceIdandroid.telephony.TelephonyManager.getDeviceId()获取 DeviceId
MIITMethods.TelephonyManager.getDeviceIdWithIntandroid.telephony.TelephonyManager.getDeviceId(Int)获取 DeviceId
MIITMethods.TelephonyManager.getImeiandroid.telephony.TelephonyManager.getImei()获取 Imei
MIITMethods.TelephonyManager.getImeiWithIntandroid.telephony.TelephonyManager.getImei(Int)获取 Imei
MIITMethods.TelephonyManager.getSubscriberIdandroid.telephony.TelephonyManager.getSubscriberId()获取 SubscriberId

作者:LOPER7
来源:juejin.cn/post/7307470097663688731
收起阅读 »

Android启动优化实践 - 秒开率从17%提升至75%

一、前言 启动性能是App使用体验的门面,耗时过长会降低用户使用兴趣。对于开发者来说,通过各种技术手段来提升启动性能缩减启动时长,对整站业务的各项指标提升都会有较大帮助。因此,秒开率优化也成为了各个客户端团队在体验优化方向上十分重要的一环。 本文将会结合我自己...
继续阅读 »

一、前言


启动性能是App使用体验的门面,耗时过长会降低用户使用兴趣。对于开发者来说,通过各种技术手段来提升启动性能缩减启动时长,对整站业务的各项指标提升都会有较大帮助。因此,秒开率优化也成为了各个客户端团队在体验优化方向上十分重要的一环。


本文将会结合我自己在项目中优化启动速度的经验,跟大家分享下,我眼里的科学的启动速度优化思路。


在我的眼里,科学的优化策略是通用的,不管是针对什么性能指标不管是针对什么课题,思路是差不多的。比如这期的分享是启动优化,其实跟上期分享的 如何科学的进行Android包体积优化 - 掘金 (juejin.cn) 是类似的,在那篇分享里我总结了基本思想:




  1. 抓各个环节

  2. 系统化方案先行,再来实操

  3. 明确风险收益比及成本收益比

  4. 明确指标以及形成一套监控防劣化体系

  5. 把包体积这个指标刻在脑子里



那放在启动优化里,基本思想长啥样呢?其实是差不多的,我认为唯一的差别就是启动优化会包含着许多前期调研和计划过程考虑不到或者甚至无法提前考虑的问题点和可优化点。




  1. 抓各个环节

  2. 系统化方案先行,再来实操

  3. 从各个角度死磕各个角落,寻找可优化点

  4. 明确风险收益比及成本收益比

  5. 明确指标以及形成一套监控防劣化体系

  6. 把秒开率这个指标刻在脑子里



我们调研了市面上几乎所有的优化方案,并结合项目现有情况,针对启动阶段各方面、各阶段进行针对性的优化,加上后期不断的调优,将Android客户端的主版本秒开率由 17% 优化到了 75+%,90 分位 App的启动耗时从 2800ms 优化到 1500 ms,大幅提升app的启动体验。此外,我们还建立了一系列的线上监控、防劣化体系,以确保优化效果可持续。


二、评估优化需求


在开展工作前,我们首先得先有两点判断:



  1. 是否需要进行优化

  2. 优化到多少算符合预期


那具体应该如何进行判断呢?有个比较简单的方法就是对标,找到对标对象进行对标。


对于小厂来说,一般对标对象有:



  1. 竞品App

  2. 业内人气App


基于对标对象,我们可以粗略的有如下判断:



  1. 如果我们App跟竞品App启动速度差不多甚至略高,那就有必要进行启动速度优化。当然,在许多厂不缺人力和资源的时候,并不需要这一步判断过程哈

  2. 优化到业内人气App中启动速度最快那一档即可


上述判断还是基于用户视角,从用户使用App的意愿去出发,那从业务或者技术的视角出发的话,肯定是越快越好,不废话了,下面直接进入主题。


三、秒开率定义 / 启动速度指标定义


既然要讨论秒开率,必须得明确以下问题:



  1. 用户启动app的耗时如何定义?

  2. 多长时间内app启动完成才算秒开?


3.1 用户启动app的耗时如何定义?



我们这里的定义是:进程启动 -> 用户看见Feed流卡片/内容



业内常见的app启动过程阶段一般分为「启动阶段」和「首刷阶段」。



  • 启动阶段:指用户点击icon到见到app的首页

  • 首刷阶段:指用户见到app的首页到首页列表内容展现



很多厂在谈论启动优化时,只涉及到第一步。对用户而言,他并不知道所谓的启动阶段和首刷阶段都是些什么东西,他只知道 看没看到预期的内容和能不能开始消费内容 ,既然要讨论秒开率,必然是用户期待的内容秒开,所以仅以「启动阶段」作为秒开率的指标依据并不能代表用户的真实用户体验。假如用户1s就看到首页了,但是feed流过了10s才有内容的展现,这显然不能算作秒开,这也不能代表用户的体验就是好的。当然,以 启动阶段 + 首刷阶段 认定为需要秒开的时间段,挑战也自然而然增大许多,环节越多,不可控和不可抗因素就越多。


3.2多长时间内app启动完成才算秒开?



1秒



既然谈论的目标是秒开率,那必然是需要 1s 内完成用户的启动流程。


四、认识自家App的启动流程


为什么要认识自家App的启动过程?



知彼知己者,百战不殆。 --《孙子·谋攻》



对启动过程作业务层面、系统层面的全路径分析,有利于我们发现全路径上各个阶段的耗时点及潜在耗时点。


4.1 从业务角度看启动过程


为什么要从业务角度看启动过程?这是因为我们既然要优化的是秒开率,而我们的秒开率指标又是与业务强相关的指标,所以我们必须从业务的角度出发,找到对启动流程影响最大的以及会block启动流程的业务,以他们为切入点尝试寻求启动速度更优的解法。


启动过程最大的不确定性因素来自于网络请求,如果App启动过程中,需要等待网络请求完成才能进入下一阶段,当出现弱网、慢网等情况时,启动时长就无法预估了,我们从下图中可以看到两处网络依赖:开屏广告请求、Feed列表请求。其他的初始化任务都不依赖网络,自然而然的执行时长在同一机器、同一环境是比较稳定的,也是可观测的。


根据业务流程,我们想要优化启动速度,需要进行如下考虑:



  • 开屏广告接口尽量早的发出请求

  • 等待开屏接口过程中,尽量完成更多的对启动流程有 block 的启动任务

  • feed列表的第一屏数据尽量走缓存


(下图画的有点粗略,意会就行)



4.2 从系统角度看启动过程


从系统角度来看自家App的启动路径,与大多数App是类似的。整体分为 Application 阶段、Activity阶段、RN阶段(业务页面阶段)。



4.2.1 Application阶段


在Application阶段中,有两个子阶段需要我们重点关注:



  1. Install provider,现在许多的三方库为了追求使用的便利性以及能够轻松的获取到Application上下文,会选择通过注册ContentProvider来实现库的初始化,然而正是由于过于便利,导致我们在排查启动过程的三方库初始化情况时,容易忽略掉这些隐式初始化的三方库。

  2. Application#onCreate,一般来说,项目本身模块的初始化、各种三方库初始化、业务前置环境初始化都会在 Application#onCreate 这个生命周期里干,往往这个生命周期里的任务是非常臃肿的,我们优化Application流程的大部分目光也集中在这里,也是我们通过异步、按需、预加载等各种手段做优化的主要时机。


4.2.2 Activity阶段


Activity阶段的起点来自于 ActivityThread#performLaunchActivity 的调用,在 performLaunchActivity 方法中,将会创建Activity的上下文,并且反射创建Activity实例,如果是App的冷启动(即 Application 并未创建),则会先创建Application并调用Application的onCreate方法,再初始化Activity,创建Window对象(PhoneWindow)并实现Activity和Window相关联,最终调用到Activity的onCreate生命周期方法。


在启动优化的专项中,Activity阶段最关键的生命周期是 Activity#onCreate,这个阶段中包含了大量的 UI 构建、首页相关业务初始化等耗时任务,是我们在优化启动过程中非常重要的一环,我们可以通过异步、预加载、延迟执行等手段做各方面的优化。


4.2.3 RN阶段(首页页面业务阶段)


在我们App中,有着非常大量的 react native(RN) 技术栈的使用,面首页也是采用 RN 开发,在目前客户端团队配置下,对 RN 这个大项目的源码进行优化的空间是比较小的,考虑到成本收益比,本文几乎不会涉及对 RN 架构的直接正向优化,尽管 RN 的渲染性能可能是启动流程中非常大的瓶颈点。


五、衡量App启动时长


5.1 线上大盘观测


为了量化指标、找出线上用户瓶颈以及衡量后续的优化效果,我们对线上用户的启动时长进行了埋点统计,用来观测用户从点击icon到看到feed流卡片的总时长以及各阶段的时长。


通过细化、量化的指标监控,能够很好的观测线上用户启动耗时大盘的版本变化以及各个阶段的分位数版本变化,同时我们也需要依赖线上的性能监控统计来判断我们在某个版本上上线的某个优化是否有效,是否能真实反映在大盘指标以及用户体验上,因为本地用测试机器去跑平均启动时间,受限于运行环境的不稳定,总是会有数据波动的。当进行了某项优化之后,能通过本地测试大概跑出来是否有正向优化、优化了大概多少毫秒,但是具体反映到秒开率指标上,只能依赖大盘,本地无法做上线前的优化预估。


启动流程终点选取



终点不需要完全准确,尽量准就足够了



大多数的 App 在选择冷启动启动流程终点时,会选择首页 Activity 的 onWindowFocusChanged 时机,此时首页 Activity 已经可见但其内部的 View 还不可见,对于用户侧已经可以看见首页背景,同时会将首页内 View 绘制归入首刷过程中。


但是我们期望的终点是用户看到 Feed 流卡片的时刻,上面也说了,我们优化目标就是 「启动阶段」 + 「首刷阶段」,由于首页里的feed tab是RN 开发的,我们无法轻易的去精准到卡片的绘制阶段,于是我们将终点的选取选在了「ReactScrollView 的子 View onViewAttachedToWindow 回调时」,指RN feed流卡片View ready并且添加到了 window 上,可以大致认为卡片用户可见。


启动性能指标校准


由于启动路径十分复杂,在添加了相应的埋点之后还需要进行额外的校准,确保启动性能面板能正确反映用户正常打开app看到首页的完整不间断流程的性能。因此,我们对于许多的边缘case进行了剔除:



  • 进程后台被唤起,这种情况进程在后台早早的被唤起,三方sdk以及一些其他模块也早早的初始化完成,只是MainActivity没有被启动,这种case下,我们通过进程启动时,读取进程当前的 importance 进行过滤。

  • 启动过程中App退后台

  • 用户未登录场景

  • 特殊场景下的开屏广告,比如有复杂的联动动效

  • push、deeplink拉起

  • 点开app第一个页面非首页,这种场景常见的就是 push、deeplink,但是还会有一些其他的站内逻辑进其他tab或者其他二级页面,所以这里统一做一个过滤。


启动性能大盘建设


首先要明确我们建设的性能大盘是以什么形式从什么角度监控什么指标,在启动速度这种场景下,分位数指标更加适合去进行全面监控,因为其波动较小,不会被极端的case影响曲线,所以我们在进行启动性能的优化时,最关注的就是分位数的性能。所以我们的整体监控面板分为:



  • 秒开率、2秒开率、3秒开率、5秒以上打开率

  • 90分位总体性能、90分位各阶段性能。

  • 分版本各阶段各项指标、整体各阶段各项指标、主版本各阶段各项指标。

  • 分场景,如有无广告等等

  • ...


5.2 Method Trace


除了线上对性能指标进行监控,在开发环境下我们想要去优化启动时长,必须得有方法知道瓶颈在哪儿,是哪个方法太耗时,还是哪些逻辑不合理,哪些能优化,哪些没法优化。Method Trace就是其中手段之一,我们通过 Method Trace能看到每个线程的运行情况,每个方法、方法栈耗时情况如何。


Profiler


看method trace有两种方式,一种是直接启动 Android Studio 自带的Profiler工具,attach上进程之后就可以选择 “sample java methods” 来进行 cpu profiling了,但是这种方式不支持release包使用,只能在debug包上面使用,且性能损耗大,会让问题的分析产生误差


Debug.startMethodTracingSamping


我们也可以通过代码去抓取 method trace:


Application#onCreate:
File file = new File(FileUtils.getCacheDir(application), "trace_" + System.currentTimeMillis());
Debug.startMethodTracing(file.getAbsolutePath(), 200000000);
Debug.startMethodTracingSamping(file.getAbsolutePath(), 200000000, 100);

StartupFlow#afterStartup:
Debug.stopMethodTracing();

在开启 MethodTracing 时,更加推荐使用 startMethodTracingSamping,这样性能损耗比调用startMethodTracing进行的完全的 Method Tracing低非常非常多, 这样抓到的性能窗口误差也小很多。而且抓到的 trace 文件也小很多,用Android Studio直接打开的话,不会有啥问题,当文件足够大时,用Android Studio打开可能会失败或者直接卡死。



5.3 System Trace


Perfetto


Perfetto 是 Android 10 中引入的全新平台级跟踪工具。这是适用于 Android、Linux 和 Chrome 的更加通用和复杂的开源跟踪项目。与 Systrace 不同,它提供数据源超集,可让你以 protobuf 编码的二进制流形式记录任意长度的跟踪记录。你可以在 Perfetto 界面中打开这些跟踪记录,可以理解成如果开发机器是 Android 10 以下,就用 Systrace,如果是 Android 10 及以上,就用 Perfetto,但是 Perfetto跟Systrace一样,抓取到的报告是不包含 App 进程的代码执行情况的。文章后续也会给用Perfetto找到待优化点的案例。


六、优化实践


经过上面的理论分析、现状分析以及大盘指标的建立之后,其实大致对哪些需要优化以及大致如何排查、如何优化会有一个整体认知。在小厂里,由于开发资源有限,所以实际上在优化实践阶段对能进行的但是进行人力成本比较高的优化项会做一轮筛查,我们通过调研市面公开的资料、技术博客了解各大场以及各个博主分享的启动优化思路和方案,再结合自身场景做出取舍,在做取舍的过程中,衡量一个任务是否要启动有两个关键指标:“投入产出比”、“产出风险比”



  1. 投入产出比:很容易理解,当优化一个点需要 3人/天,能收获90分位 100ms 的优化产出,另一个点需要 3人/天,但只能收获90分位 10ms 的优化产出,谁先谁后、谁要做谁不做其实显而易见,因为优化的前期追求的一个很重要的点必然是优化收益。等到后续开启二期任务、三期任务需要做到更加极致时,才会考虑成本高但收益低的任务。

  2. 产出风险比:在我们做启动优化过程中,必然会有一些方案有产出但是可能会有风险点的,可能是影响某个业务,也可能影响所有业务,我们需要在启动优化的过程中,不断的衡量一个优化任务是否有风险,风险影响范围如何,风险影响大小如何,最后整体衡量甚至跟业务方进行商讨对他们造成的劣化是否能够接受,最后才能敲定相关的任务是否要排上日程。


所以大致的思路可以总结为:



  1. 前期低成本低风险快速降低大盘启动耗时

  2. 后期高成本突破各个瓶颈

  3. 全期加强监控,做好防劣化


下面我们就将会按照文章一开始提过的启动流程顺序来分享在启动加速项目中的一些案例。


6.1 Application流程


6.1.1 启动任务删减与重排



这里我多提两嘴。我个人觉得在启动优化中,删减和重排启动任务是最为复杂的,特别是对于中大型App,业务过于多,甚至过于杂乱。但是在小厂中,完全可以冲着 删除和延后所有首页无关业务、模块、SDK代码 的目标去,前提是能理清所有业务的表面和隐藏逻辑,这里需要对整个App启动阶段的业务代码和业务流程全盘掌控。




你也许可以通过奇技淫巧让启动过程中业务B反序列化时间无限变短,而我可以从启动过程中删掉业务B逻辑



在App的启动流程中,有非常多的启动任务全部在Application的onCreate里被执行,有主线程的有非主线程的,但是不可避免的是,二者都会对启动的性能带来损耗。所以我们需要做的第一件重要的事情就是 减少启动任务。
我们通过逐个排查启动任务,同时将他们分为几类:



  • 刚需任务:不可延迟,必须第一时间最高优先级执行完成,比如网络库、存储库等基础库的初始化。如果不在启动阶段初始化完成,根本无法进入到后续流程。

  • 非刚需高优任务:这类任务的特征就是高优,但是并非刚需,并不是说不初始化完成后续首页就没法进没法用,比如拉取ab实验配置、ip直连、push、长链接相关非刚需基础建设项,这类可以高优在启动阶段执行,但是没必要放在 UI 线程 block 执行,就可以放到启动阶段的后台工作线程中去跑。

  • 非刚需低优任务:这类任务常见的特征就是对业务能否运作无决定性影响或者业务本身流程靠后,完全可以放在我们认为的启动阶段结束之后再后台执行,比如 x5内核初始化、在线客服sdk预初始化 之类的。

  • 可删除任务:这类任务完全不需要存在于启动流程,可能是任务本身无意义,也可能是任务本身可以懒加载,即在用到的时候再初始化都不迟。


将任务分类之后,我们就能大概知道如何去进行优化。



  • 拉高刚需任务优先级

  • 非刚需高优 异步化

  • 非刚需低优任务 异步化+延迟化

  • 可删除任务 删除


6.1.2 任务排布框架


为了更加方便的对启动任务进行排布,我们自己实现了一套用于启动过程的任务排布框架TaskManager。TaskManager具有以下几个特性:



  1. 支持优先级

  2. 支持依赖关系

  3. 提供超时、失败机制以供 fallback

  4. 支持在关键时间阶段运行任务,如MainActivity某个生命周期、启动流程结束后


大致使用方式为:


TaskManager.getInstance().beginWith(A)
.then(B)
.then(C, D)
.then(E)
.enqueue();

TaskManager.getInstance().runAfterStartup({ xxx; })

通过任务的大致非精细化的排布,我们不仅仅可以对启动任务能够很好的进行监控,还可以更加容易的找出不合理项。


6.1.3 实现runAfterStartup机制 + idleHandler



这玩意儿十分重要,我通过昏天黑地的梳理业务,将启动流程中原先可能超过一半的代码任务非常方便的放到了启动流程之后



我们通过提供 runAfterStartup 的API,用于更加容易的支持各种场景、各种业务把自己的启动过程任务或者非启动过程任务放在启动流程结束之后运行,这也有助于我们自己在优化的过程中,更加轻松的将上面的非刚需低优任务进行排布。


runAfterStartup的那些任务,应该在什么时候去执行呢?
这里我们认定的启动流程结束是有几个关键点的:



  1. 首页tab的feed流渲染完成

  2. 首页tab加载、渲染失败

  3. 用户进入了二级页面

  4. 用户退后台

  5. 用户在首页有 tab 切换操作


通过TaskManager的使用以及我们对各业务的逐一排查分析,我们将原先在启动阶段一股脑无脑运行的任务进行了拆解和细化,该延后的延后,该异步的异步,该按需的按需。


6.2 Activity流程


接下来将分享一下 Activity 阶段的一些相关优化的典型案例。


6.2.1 SplashActivity与MainActivity合并


原先的 launcher activity 是SplashActivity,主要承载广告逻辑,当App启动时,先进入SplashActivity,死等广告接口判断是否有开屏广告,如果有则展示,没有则跳转进MainActivity,这里流程的不合理性影响最大的点是:SplashActivity在死等开屏接口时,根本无法对业务本身做一些预加载或者并发加载,首页的业务都在MainActivity里面,同时启动阶段需要连续启动两个Activity,至少带来 百毫秒 级别的劣化。



当然,将SplashActivity承接的所有内容转移到MainActivity上,有哪几个挑战又该如何解决?


1. MainActivity 作为launch activity之后的单实例问题



  • MainAcitvity 的 launch mode 需要设置为 singleTop,否则会出现 App从后台进前台,非MainActivity走生命周期的现象

  • 同时,作为首页,需要满足另一个条件就是跳转到首页之后,其他二级页面需要全部都关闭掉,站内跳转到 MainActivity 则附带 CLEAR_TOP | NEW_TASK 的标记


2. 广告以什么形式展现



  • 广告原先是以Activity的形式在展现,当 launcher 换掉之后,广告被抽离成 View 的形式去承载逻辑,在 MainActivity#onCreate 中,通过将广告View添加进 DecorView中,完成对首页布局的遮罩,这种方式还有一个好处就是在广告View展示白屏logo图时,底下首页框架是可以进行预加载的。

  • 这里其实还需要实现以下配套设施:

    • 首页弹出层管理器,管理弹窗、页面跳转等各种可能弹出的东西,在广告View覆盖在上面时,先暂停弹出层管理器的生命周期,避免出现其他内容盖住广告




6.2.2 异步预加载布局



使用异步加载布局时,可以对AsyncLayoutInflater小小改造下,如单线程变线程池,调高线程优先级等,提升预加载使用率



在Android中,其实有封装较好的 AsyncLayoutInflater 用于进行布局的异步加载,我们在App的启动阶段启动异步加载View的任务,同时调高工作线程优先级以尽量在使用View之前就 inflate 结束,这样在首页上要使用该布局时,就可以直接从内存中读取。


异步加载布局容易出问题的点有:



  1. Context的替换



    • 使用MutableContextWrapper,在使用时替换为对应的 Activity 即可



  2. theme问题



    • 当异步创建 TabLayout 等Matrials组件时,由于Application的主题并没有所谓的 AppCompat 主题,会抛出异常 You need to use a Theme.AppCompat theme 。这时需要在 xml 中加上 android:theme="@style/AppCompatTheme" 即可。




但是异步预加载布局有一个点是非常关键的:使用前一定要确认,异步创建的这个布局大部分时候或者绝大部分时候,都能在使用前创建好,不然的话不仅没有优化的效果,还会增加启动阶段的任务从而对启动性能带来一定程度上的劣化。


6.2.3 多Tab懒加载


我们App的首页结构非常复杂,一共有着三层的tab切换器。底部tab负责切换大tab,首页的tab还有着两层tab用于切换二三级子页面。原先由于代码设计使然,首页Tab的其他所有子页面都会在 App 的启动阶段被加载,只是说不进行 RN 页面的渲染,这其实会占据 UI 线程非常多的时间。


我们做启动优化的过程中,将一二三级tab全部懒加载,同时由于 我们App存在首页其他 Tab 的预加载技术建设,目的是为了实现当用户切到其他tab时,完全去除加载过程,因此我们也将预加载的逻辑延迟到了启动流程之后,即:


StartupFlow.runAfterStartup{ preload() }

6.2.4 懒加载布局


Tab懒加载其实也算是布局懒加载的一部分,但又不全是,所以给拆开了。这部分讲的布局懒加载是指:



  • 启动过程不一定会需要用上的布局,可以完全在需要时被加载,比如:

    • 广告View,完全可以在后端广告接口返回可用的广告数据结构且经过了素材校验等流程确定要开始渲染广告时,进行布局的加载。

    • 首页上其他非全量的布局,比如其他广告位、首页上并不一定会出现的搜索框、banner 组件等。这些组件的特性是根据不同的配置来决定是否展示,跟广告类似。




我们用上的布局懒加载的手段分几种:



  • ViewStub,在开屏广告的布局中非常常见,因为广告有多种类型,如视频、图片、其他类型广告等,每次的开屏又是确定的只有一种,因此就可以将不同类型的广告容器布局用 ViewStub 来处理

  • Kotlin by lazy,这种就是适用于 布局是动态加载的场景,假如上面描述的开屏广告的各种不同类型素材的布局都是在代码中动态的 inflate 并且 add 到根View上的话,其实也可以通过 by lazy 的方式来实现。所以其实很多时候 by lazy 用起来会更加方便,比如下图,对 binding 使用 by lazy ,这样只有在真正要使用 binding 时,才会去 进行 inflate。
    image.png


6.2.5 xml2Code


用开源项目可以,自己实现也可以,当然,搭配异步加载更可以。


6.2.6 减少布局层级、优化过度绘制


这个就需要自己通过LayoutInspector和Android的调试工具去各自分析了,如果打开LayoutInspector肉眼可见都是红色,赶紧改。。


6.3 RN流程



这里也顺带吐槽下吧,用 RN 写首页 feed 流的app真的不多哈,一般来说随着公司的发展,往往会越来越注重关键页面的性能,我们项目是我见过的为数不多的进 App 第一个页面还是RN的。如果首页不是RN,上面提到的秒开率指标、启动耗时应该会更加好一些才对。



image.png


先直面 RN 页面作为首页的加载流程,在页面进行渲染前,会比 native 页面多几个前置任务:



  1. RN 框架初始化(load各种so,初始化各种东西),RN init

  2. RN bundle 文件准备,find RN bundle

  3. RN bundle 文件加载,load RN bundle

  4. 开启RN渲染流程,start react application


又因为 RN 的 js 和 native 层进行通信,又有引擎开销和线程调度开销,所以我个人认为 RN 是不足以胜任主流 app 的搭载首页业务的任务的


吐槽归吐槽,能尽量优化点是一点:


6.3.1 无限提前 RN init


如题,由于后续所有的RN流程都依赖RN环境的初始化,所以必须将这坨初始化调用能放多前面,就放多前面,当然,该子线程的还是子线程哈,不可能主线程去初始化这东西。


6.3.2 前置 RN bundle 的完整性校验


当我们在使用 RN 的 bundle 文件时,往往需要校验一下 md5,看看从CDN上下载或者更新的 bundle 文件是否完整,但是启动流程如果进行 bundle 文件的 md5 校验,往往是一个比较2的举动,因此我们通过调整下载流程和加载流程,让下载/更新流程进行完整性校验的保护,确保要加载的所有 bundle 都是完整的可用的就行了,就不需要在启动流程校验bundle完整性了。


6.3.3 page cache


给首页的feed流增加页面级别缓存,让首页首刷不依赖接口返回


6.3.4 三方库的初始化优化


部分的三方 RN package,会在RN初始化的时候,同步的去初始化一堆耗时玩意或者执行耗时逻辑,可以通过修改三方库代码,让他们变成异步执行,不要拖慢整体的RN流程。


6.4 其他优化


6.4.1 Webview非预期初始化


在我们使用 Perfetto 进行性能观测时,在 UI 线程发现了一段 几十毫秒接近百毫秒 的非预期Webview初始化的耗时(机器环境:小米10 pro),在线上用户机器上这段代码执行时间可能会更长。为什么说非预期呢:



  • 首页没有WebView的使用、预加载

  • X5内核的初始化也在启动流程之后


1701274423005.png


我们从perfetto的时序图中可以看到,堆栈的调用入口是WebViewChromiumAwInit.startChromiumLocked,由于 Perfetto 并看不到 App 相关的堆栈信息,所以我们无法直接知道到底是哪行代码引起的。这里感谢一下 抖音团队 分享的启动优化案例解析中的案例,了解到 WebViewFactory 实现的 WebViewFactoryProvider 接口的 getStatics、 getGeolocationPermission、createWebView 等多个方法的首次调用都会触发 WebViewChromiumAwInit#ensureChromiumStartedLocked ,随之往主线程 post 一个 runnable,这个runnable的任务体就是 startChromiumLocked 函数的调用。


所以只要我们知道谁在调用 WebViewFactoryProvider 的接口方法,就能知道调用栈来自于哪儿。于是乎我们开始对 WebViewFactoryProvider 进行动态代理,用代理对象去替换掉 WebViewFactory 内部的 sProviderInstance。同时通过断点、打堆栈的形式来查找调用源头。


    ##WebViewFactory
@SystemApi
public final class WebViewFactory{
//...
@UnsupportedAppUsage
private static WebViewFactoryProvider sProviderInstance;
//...
}


##动态代理
try {
Class clas = Class.forName("android.webkit.WebViewFactory");
Method method = clas.getDeclaredMethod("getProvider");
method.setAccessible(true);
Object obj = method.invoke(null);

Object hookService = Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getSuperclass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Log.d("zttt", "hookService method: " + method.getName());
new RuntimeException(method.getName()).printStackTrace();
return method.invoke(obj, args);
}
});

Field field = clas.getDeclaredField("sProviderInstance");
field.setAccessible(true);
field.set(null, hookService);
} catch (Exception e) {
e.printStackTrace();
}

替换掉 sProviderInstance 之后,我们就可以在我们的代理逻辑中,加上断点来进行调试,最终找到了造成 WebView非预期初始化的始作俑者:WebSettings.getDefaultUserAgent



事情到这里就好解决了,只需要对 WebSettings.getDefaultUserAgent 进行编译期的Hook重定向到带缓存defaultUserAgent 的相关方法就行了,本地有缓存则直接读取,本地没有则立即读取,得益于我们项目中使用方便的 配置化 Hook 框架,这种小打小闹的 Hook 工作不到一分钟就能完成。



参考:


基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)


AndroidSimpleHook-github





当然,这里还需要考虑一个问题,那就是当用户机器的 defaultUserAgent 发生变化之后,怎么才能及时的更新本地缓存以及网络请求中用上新的defaultUserAgent。我们的做法是:



  • 当本地没有缓存时,立刻调用 WebSettings.getDefaultUserAgent 拿值并更新缓存;

  • 每次App启动阶段结束之后,会在子线程中去调用WebSettings.getDefaultUserAgent 拿值并更新缓存。


这样处理之后,将 defaultUserAgent 发生变化之后的影响最小化,系统 WebView 升级本身就是极度不频繁的事情,在这种 case 下我们舍弃了下一次 App 打开前几个网络请求的 defaultUserAgent 正确性也是合理的,这也是我们考量 「风险收益比」的一个经典case。


6.4.2 启动阶段 IO 优化



更加高级的做法是进行请求合并,当我们将50+个网络请求优化到十来个的时候,如果再对一些实时性不是极高的接口进行合并,会更加优雅。不过小厂没资源做。



前面说的都是优化 UI 线程相关的耗时,实际上在启动阶段,不仅仅 UI 线程执行太多耗时任务会影响启动速度,工作线程执行太多的耗时任务也会影响到启动速度,特别是重IO的任务。


在我们App的启动流程里,首页完成渲染前需要发送50+个网络请求,包含:



  • 业务预拉取数据

  • 业务拉取各种配置、开关、实验变量

  • 多次的badge请求

  • 多次的 IM 拉消息的请求

  • 各种首页阶段是否展示引导的请求,如push引导权限

  • 其他莫名奇妙的请求,可能业务都废弃了,还在发送请求


在优化这一项时,还是秉承着前面所说的得谨慎的考虑 “收益风险比”,毕竟我们不是对所有业务都非常了解,且公司现有研发也不是对所有业务都非常了解。通过一波深入的调研和调整之后,我们将 App 首页渲染完成前的网络请求数量,控制在 10 个左右,大大的减少了启动阶段的网络请求量


6.4.3 大对象反序列化优化


我们App中,对象的反序列化、序列化用的是谷歌亲儿子 - Gson。Gson 是 Google 推出的一个 json 解析库,其具有接入成本低、使用便捷、功能扩展性良好等优点,但是其也有一个比较明显的弱点,那就是对于它在进行某个 Model 的首次解析时会比较耗时,并且随着 Model 复杂程度的增加,其耗时会不断膨胀。
而我们在启动过程中,需要用 Gson 反序列化非常多的数据,特别是某些大对象,如:



  • Global config:顾名思义,是一个全局配置,里面包含着各个业务的配置信息,是非常大的

  • user info:这是用户信息的描述对象,里面包含着非常多的用户属性、标签,在App 启动过程中如果主线程去初始化,往往需要几十甚至上百毫秒。


针对这种启动阶段必须进行复杂对象序列化,我们进行了:



  • 用Gson解的,自定义 TypeAdapter,属于正面优化反序列化操作本身(市面上有现成的一些通过添加注解自动生成TypeAdapter的框架,通过一个注解就能够很轻松的给对应的类生成 TypeAdapter并注册到 Gson 中)

  • 又大又乱又不好改的直接读磁盘然后 JSON 解析的大对象(没想到还有这种的吧),提前子线程预加载,避免在 UI 线程反序列化,能解决部分问题,并非完全的解法


6.4.4 广告流程优化


其实聊到启动优化,必然会涉及的肯定是 开屏广告 的业务流程。首先要搞清楚一个问题。


启动优化优化的是什么?
启动优化优化的是用户体验,优化的是用户看到内容的速度,那么开屏的内容怎么就不算呢?所以实际上加速用户看到开屏也能一定程度上让用户体感更加的好。而且由于我们进首页的时间依赖于广告流程结束,即需要等待广告流程结束,我们App才会跳过logo的全屏等待页面进入首页,那么优化广告流程耗时实际上也是在优化进入首页的速度,即用户可以更加快速的看到首页框架。



原先的广告流程如上图,业务流程本身可能没什么问题,问题出在了两次的往主线程post runnable来控制流程前进。已知 App 启动流程是非常繁忙的,当一个 runnable 被post到 UI 线程的队列中之后不会立即执行,可能需要等上百甚至几百毫秒,而且由于启动过程中有着许多的耗时大的 runnable 在被执行,就算 mainHandler.postAtFrontOfQueue 都无济于事。



因此我们对广告流程做了调整,去掉了其中一次的消息抛回主线程执行的逻辑,加快了广告业务的流程执行速度,同时,受益于我们前面说的 View 的异步预加载、懒加载等手段,广告流程的执行速度被全面优化。


6.4.5 GC 抑制



实现的花可以参考大佬博客: 速度优化:GC抑制 - 掘金 (juejin.cn)


大家如果只是想本地测试下 GC 抑制在自己项目里的效果,反编译某些大厂App,从里面把相关 so 文件捞出来,copy 下JNI声明,放自己项目里测试用就行。



自动垃圾回收的Java特性相对于C语言来说,确实是能够让开发人员在开发时提高效率,不需要去考虑手动分配内存和分配的内存什么时候去手动回收,但是对于Java程序来说,垃圾回收机制真实让人又爱又恨,不过如果开发人员仅在乎业务而不在乎性能的话,确实是不会垃圾回收恨起来。这里需要明确一点,垃圾回收是有代价的,会占 CPU 资源甚至导致我们线程被挂起。


App在启动过程中,由于任务众多而且涉及到的sdk、模块也众多,非常容易在启动阶段发生一次或者多次的GC,这往往会带来比较多的性能损耗。


针对这种启动容易触发 GC 的场景,我们有两种方式去减少 GC 次数以及降低 GC 发生的可能。



  • 通过 profiler 去排查启动过程中的对象创建、分配,找出分配过于频繁明显不正常的case

  • 影响 ART 虚拟机的默认垃圾回收行为和机制,进行 GC 抑制,这里由于我们 App 的 minSdk 是 Api 24,所以仅涉及 ART 虚拟机上的 GC 抑制。


不过鉴于以下几点,我们最终并没有自己去实现和上线无稳定性、兼容性隐患的GC抑制能力:



  1. Android 10 及以上,官方实际上已经在 framework 中添加了 App 启动提高 GC 阈值的逻辑。cs.android.com/android/_/a…

  2. 由于我们在启动任务重排和删减用例很大,线上对 启动阶段的 GC 次数进行了统计,发现 80 分位的用户 GC 次数为0。也就是说启动优化上线之后线上至少 80% 的用户在启动阶段都不会发生 GC。监听 GC 发生可以简单的用 WeakReference 来包装重写了 finalize 方法的自定义对象。

  3. 不满足对于收益成本比、风险收益比的要求


6.4.6 高频方法



排查高频方法可以通过 method trace + 插桩记录函数调用来做



比如在我们App的场景中,日志库、网络库上拼接公共参数时,会反复调用许多耗时且无缓存的方法去获取参数值,其中包括:



  • Binder调用,如 push enable 等

  • 每次都需要反序列化的取值,如 反序列化 global config,从中取得一个配置值。

  • ...


针对这几类问题,我们做了如下优化:



  • 能加缓存加缓存

  • 拼接公参异步化


6.4.7 binder 调用优化



想要优化启动过程中的binder调用,必须得先知道有哪些binder调用,不考虑来自Framework代码的调用的话,可以通过hook工具来先检查一下。同时打印下调用耗时摸个底
基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)



binder是android提供的IPC的方式。android许多系统服务都是运行在system_server进程而非app进程,比如判断网络,获取电量,加密等,当通过binder调用去调用到相关的api之后,app线程会挂起等待binder驱动返回数据,因此IPC 调用是比较耗时的,而且可能会出现比预期之内的耗时更加耗时的现象。


针对binder调用的耗时现象,主要做了:



  1. 对反复调用的 binder 调用结果进行缓存,合适的时机进行更新

  2. 通过 hook 框架统一收拢这些调用进缓存逻辑


比如 push enable,这种总不能启动过程变来变去吧,再比如网络状态,也不能启动过程变来变去吧。


当然,上面举的例子,也完全可以用于App全局场景,比如通知权限状态,完全可以app进前台检查下就行,比如网络状态,监听网络状态变化去更新就行。


七、验收优化效果



再次强调一下,我们统计的 App 启动耗时是「启动阶段」+ 「首刷阶段」



7.1 App 启动耗时分位数


90 分位 App的启动耗时从 2800 左右 下降到 1500 左右。降幅47%



7.2 主版本秒开率



从图中也能看到,整体稳定,但部分天波动较大,是因为开屏广告接入了程序化平台,接口时长、素材大小等都不是很好控制,尽管后端已经限制了请求程序化的超时时长,但是迫于无奈,无法将程序化平台接口请求超时时长设定在一个我满意的情况下,毕竟是收入部门。



Android 主版本秒开率由原先的约 17% 提升到 76%



7.3 两秒打开率


Android 主版本两秒打开率由原先的 75% 提升到了 93%


八、总结与展望


回顾秒开率优化的这一期工作,是立项之后的第一期,在优化项选型过程中,除了优化效果之外,人力成本是我们考虑的最多的因素,由于团队人力不充裕,一些需要比较高成本去研究、去做的优化项,也都暂时搁置留做二期任务或者无限搁置。启动优化本身就是个需要持续迭代、持续打磨的任务,我们在优化的过程中始终秉承着 高收益成本比、低风险收益比 的思想,所以后续我们还会继续钻研,继续将之前没有开展的任务排上日程,技术之路,永无止境。


防劣化跟优化一样重要


在线上优化工作开展完成且取得了相应成果后,绝对不能放松警惕,优化这一下获得了效果并不是最重要的,最重要的是要有持续的、稳定的优化效果。对于线上用户来说,其实可能并不关心这个版本或者这几个版本是不是变快了,大家可能更需要的是长时间的良好体验,对于我们这些开发者来说,长时间的良好体验可能才能改变大家对 自家 App 的性能印象,让大家认可自家 App 的启动性能,这也是我们优化的初衷。因此,防劣化跟优化一样重要!


其他


做性能优化往往是比较枯燥的,可能很长时间没有进展,但是当有成果出来时,会收获到一些幸福感和满足感。希望大家都能在遇到瓶颈很难再往前迈步时,再努力挣扎一下,如果不出意外的话,这一路一定很精彩。


作者:邹阿涛涛涛涛涛涛
来源:juejin.cn/post/7306692609497546752
收起阅读 »

如何科学的进行Android包体积优化

这篇文章会分享小厂如何做包体积优化相关主题,涉及内容包括:1) Android包体积优化的一种可能是比较标准的推进做法,2) 大致流程的心路历程和思考方式,3) 如何去总结和分享你们进行过的包体积优化项目。本文不仅仅是一篇分享,还是我个人比较喜欢的总结报告写法...
继续阅读 »

这篇文章会分享小厂如何做包体积优化相关主题,涉及内容包括:1) Android包体积优化的一种可能是比较标准的推进做法,2) 大致流程的心路历程和思考方式,3) 如何去总结和分享你们进行过的包体积优化项目。本文不仅仅是一篇分享,还是我个人比较喜欢的总结报告写法。



一、前言


移动 App 特别关注投放转化率指标,而 App 包体积是影响用户新增的重要因素,而 App 的包体积又是影响投放转化率的重要因素。


Google 2016 年公布的研究报告显示,包体积每上升 6MB 就会带来下载转化率降低 1%, 当包体积增大到 100MB 时就会有断崖式的下跌 。对于应用商店,普遍有一个流量自动下载的最大阈值,如 应用宝,下载的app超过100M,用流量下载时,会弹窗提示用户是否继续下载,这对下载转化率影响是比较大的。


现在流量虽然变得更廉价一点,但是用户的心理是不会变的,当 App 出现在应用市场的相同位置时,包体积越大,用户下载意愿可能越低。


而且包体积或直接或间接地影响着下载转化率、安装时间、运行内存、磁盘空间等重要指标,所以投入精力扫除积弊、发掘更深层次的体积优化项是十分必要的。



某手:



  • 1M = 一年kw级推广费


某条极速版:



  • Google 2016 年公布的研究报告显示,包体积每上升 6MB 就会带来下载转化率降低 1%,当包体积增大到 100MB 时就会有断崖式的下跌。

  • 通过插件化,将常规优化后达 120M+的包体积降到了 13M 左右,最小版本降至 4M,包体积缩小至原先的 3.33%。


某德:



  • 包体积大小,是俞xx直接拍的,就要求 x年x月x日 前削减到100M。


某淘:



  • 包大小做得比较“霸权”“独裁”,新业务超过 1M 要总裁审批,一般在平台组都卡掉了。



二、评估优化需求


在开展工作前,我们首先得先有两点判断:



  1. 是否需要进行优化

  2. 优化到多少算符合预期


那具体应该如何进行判断呢?有个比较简单的方法就是对标,找到对标对象进行对标。


对于小厂来说,一般对标对象有:



  1. 竞品App

  2. 业内人气App


基于对标对象,我们可以粗略的有如下判断:



  1. 如果我们App跟竞品App包体积差不多或略高,那就有必要进行包体积优化。

  2. 优化到跟业内人气App中包体积最小的那一档差不多即可。


上述判断还是基于用户视角,假如你的 用户需求 比较简单,好几个app都可以满足, 你会安装200M的产品,还是50M的产品。再有,假如用户在App商店无意间看到你们的App,有点兴趣体验体验,但是看到包体积是200M,他有多大概率会继续下载,换成50M呢?


三、包体积优化基本思想


我们在做包体积优化前,一定要定好我们的大方向,我们怎么优化,能做哪些,哪些可以现在做,哪些没必要现在做,


1. 抓各个环节


我们最终是要优化App的包体积,那么App包组成部分有哪些,我们针对每一个部分去研究如何减少其体积,就可以达到我们最终的效果。


2. 系统化方案先行,再来实操


优化Android包体积这个事情,有一定的探索性,但是很多内容或者说手段都是业内在广为流传的,既然如此,我们应该总结业内可优化手段,并逐一进行分析,研究是否适合你们App,是否需要应用到你们App。如果没有方案的埋头扎进去,往往会因为陷入细节,而丢失了全局视野。


3. 明确风险收益比及成本收益比


方案与方案之间是有区别的。如果一个方案能减少包体积2M,但是线上可能会崩溃,你会做吗? 如果一个方案能减少包体积2M,但是开发成本要一个月,你会做吗?


4. 明确指标以及形成一套监控防劣化体系


干任何一件以优化为目标的事情时,一定要明确优化的指标,我们要做App的包体积优化,那么我们的指标为:减少App安装包 apk 的大小。


当我们指标明确之后,我们还需要对现有指标进行监控,这样有两个好处:



  • 明确优化收益

  • 防止劣化


那我们就可以在某个关键的时间节点进行包体积的统计和上报,一般时间节点有:



  • App发版打包时(粒度更粗)

  • 开发分支有新的 commit 合入时(粒度更细)


两种粒度各有各的好处,但是目标是一样的:



  • 监控每次打包的包体积,可以行成指标曲线进行持续观察

  • 在包体积增加超出预期时进行及时报警


5. 把包体积这个指标刻在脑子里


自动化能发现已经发生的问题,但是把包体积这个指标刻在脑子里,能避免问题的发生。


四、自家App包体积一览


1. Android Apk结构


APK 主要由五个部分组成,分别是:



  • Dex:.class 文件处理后的产物,Android 系统的可执行文件

  • Resource:资源文件,主要包括 layout、drawable、animator,通过 R.XXX.id 引用

  • Assets:资源文件,通过 AssetManager 进行加载

  • Library:so 库存放目录

  • META-INF:APK 签名有关的信息


132d21f3-a1b6-43f6-99d0-fad7ffb81412.png


2. 你们Apk各个部分都长啥样,长多大?


这里选取示例App某个版本的安装包来做举例分析,下面是包结构图:


QQ截图20231109021600.png


浅浅分析一波包内内容


成分体积备注
assets文件夹77.8M下载.png 能看到Assets文件夹里,有着75M的RN bundle
lib75.2Ma7d410c9-75ab-4c27-9d21-9c8953567d00.png 由于我们App是兼容包,即同时兼容64位、32位,所以lib目录很大
dex16M这部分涉及到我们自己的代码及三方库代码
res6.3M这里包含各种图片资源、布局文件等
resources.arsc1.2M
其他若干

五、优化方案草案


通过调研业内常规、非常规手段,以及结合你们App的包体积现状,可以提前对优化包体积做出比较详尽的可实现、低风险、高收益方案,注意这几个点非常重要:



  • 可实现 - 可实现可以简单理解为实现成本低,一般各种性能稳定性指标都是循序渐进的推进,所以往往一期优化时,选的实操方案都是实现成本比较低的,这样能相对快速的得到比较符合心里预期的优化效果。

  • 低风险 - 线上风险必须控制在可接受的程度,这里的风险不仅仅是说App运行的性能稳定性风险,还需要判断是否会增加线上问题排查的难度,当然还会有其他的我没提到的风险项。

  • 高收益 - 不解释


所以基于我们需要的是可实现、低风险、高收益的方案,我们可以基于上面我贴的APK案例,来大致预演可能会采用哪些方案:


1. 缩减动态化方案内置代码包、资源包


一般的小厂都会比较大量的采用如RN、H5等动态化方案,不可避免的App内就会有一堆内置文件,我们看到我们示例的App中,RN内置包占了整个包体积超过 30%。当出现这种情况时,可以针对内置代码包、资源包单独立子项去推进。


那么如何进行推进呢?有同学就会说了,业务方不让我把这些玩意儿从APK里面删掉,说影响他们打开页面速度,影响页面打开速度就会影响一级指标影响收入


这时为了说服业务方,我们就得拿出一些证据,用来证明内置包的全部移除或者部分移除并不会对业务产生影响,或者说影响足够小。那就可以采取如下一些推进步骤:



  • 明确全部内置包或者部分内置包不内置的影响,假如内置包是 RN 的页面bundle,那给业务方两个数据基本上就能够说明影响



    • 页面bundle现下比例,假如因为本地没有内置的bundle,打开页面需要同步进行等待下载完成才能加载的话,现场下载比例是个比较有说服力的数据。

    • 线上bundle更新耗时,我们可以统计用户启动App后的一段指定时间,90分位能下载多少个bundle,50分位能下载多少个,10分位、5分位能下载多少个,来告诉业务方,老用户、新用户、老用户新登录等各种场景,到达业务页面的时候,有多少比例的用户能完成bundle更新。



  • 明确什么样的资源需要内置,同样用RN页面bundle举例,假如App的首页就是RN页面,那这玩意儿就必须内置了,假如一个页面在犄角旮旯,日pv才不到100,那就完全可以不需要内置。

  • 给出内置资源名单

  • 拿着内置名单和上面明确的不内置影响统计,找业务方拉会, 这一步最好是从上往下进行推进,而不是同级推进


2. 分架构打包


分架构打包能减少libs文件夹体积,libs文件夹里会包含不同架构的 so 库集合。


首先我们最终apk包是要上传到应用商店的,应用商店得支持双包上传。答案确实是支持,且应用商店推荐双包上传。


Android 官方也是有相关的api支持分架构打包:


splits {
// 基于不同的abi架构配置不同的apk
abi {
// 必须为true,打包才会为不同的abi生成不同的apk
enable true
// 默认情况下,包含了所有的ABI。
// 所以使用reset()清空所有的ABI,再使用include指定我们想要生成的架构armeabi-v7a、arm-v8a
reset()
// 逗号分隔列表的形式指定 Gradle 应针对哪些 ABI 生成 APK。只与 reset() 结合使用,以指定确切的 ABI 列表。
include "armeabi-v7a", "arm64-v8a"
// 是否生成通用的apk,也就是包含所有ABI的apk。如果设为 true,那么除了按 ABI 生成的 APK 之外,Gradle 还会生成一个通用 APK。
universalApk true
}
}

这里需要注意的是,线上并不是所有的手机都支持 64位 的安装包,应用商店可以双包上传,线上灰度更新可以下发32位的安装包或者是 32/64 兼容包。


3. So 压缩


分架构打包是减少so的数量,so压缩是减少so的单个体积。


android:extractNativeLibs="true"

android:extractNativeLibs = true时,gradle打包时会对工程中的so库进行压缩,最终生成apk包的体积会减小。


但用户在手机端进行apk安装时,系统会对压缩后的so库进行解压,从而造成用户安装apk的时间变长。


若开发人员未对android:extractNativeLibs进行特殊配置,android:extractNativeLibs默认值:



  • minSdkVersion < 23 或 Android Gradle plugin < 3.6.0情况下,打包时 android:extractNativeLibs=true

  • minSdkVersion >= 23 并且 Android Gradle plugin >= 3.6.0情况下,打包时android:extractNativeLibs=false


4. 大so动态下发


我们能看到有些so库单个体积超大,放在apk里,就算能压缩,压缩后体积仍然很大,可能会占到 app体积超过 10%。针对这种情况,选择动态下发。


a91934eb-420a-4e74-8b38-5e899876d89a.png


动态下发的so如何进行加载


我们采用ASM的方案,对代码中所有的 System.load、System.loadLibrary 进行hook,进入到我们自己的逻辑,这样我们就可以走下面流程:



  1. 下载so库

  2. 解压so库

  3. 校验so库

  4. 加载so库


这里需要注意的一点就是,当动态下发的so没有下载、解压、校验、加载完之前,如果用户进入到了相关的业务场景,必须有兜底机制。比如在样例App的场景中,使用了 opencv 库来做图片的二维码识别,当so没下载下来时,要识别二维码就会被兜底到 zxing。


而且由于我们有较好的Hook框架的封装,所以我们需要hook时,仅仅需要进行配置即可:



这里可以参考我之前的博客和github上demo项目:


基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)


AndroidSimpleHook-github


5. 大文件压缩优化,对内置的未压缩大文件进行,压缩文件用高压缩率的压缩算法


假如apk里有内置的大文件,可以通过对其进行压缩从而减少包体积,压缩时可以选用高压缩率的算法。


6. 代码优化



  • 去除无用代码、资源


去除无用代码我们可以用官方的Lint检查工具



  • 去除无用三方库



  • 减少ENUM的使用


每减少一个ENUM可以减少大约1.0到1.4 KB的大小,假如有10000个枚举对象,那不就减少了14M?美滋滋啊,但实际上具体还是要看项目代码情况来考虑,毕竟不是所有的项目里都有 10000 个枚举。


7. 资源优化



  • 无用资源文件清理


去除无用资源文件可以通过lint工具来做,也可以通过微信开源的 ApkChecker来完成。


github.com/Tencent/mat…


图片压缩、转webp

图片压缩可以使用TinyPng,AndroidStudio也有相关插件,官方术语就是:


使用智能的无损压缩技术来减少图片文件的大小,通过智能的选择颜色的数量,减少存储的字节,但是效果基本是和压缩前一样的。


图片着色器

相同图片只是颜色不同的话,完全可以只放一个图片,在内存里操作 Drawable,完成颜色替换。


图片动态下发

如果本地有大图,且使用要求为未压缩,或者压缩之后仍然很大,可以适当的选择动态下载该图。


resources.arsc资源混淆

resources.arsc这个文件是存放在APK包中的,他是由AAPT工具在打包过程中生成的,他本身是一个资源的索引表,里面维护者资源ID、Name、Path或者Value的对应关系,AssetManager通过这个索引表,就可以通过资源的ID找到这个资源对应的文件或者数据。


通过对apk 中的resources.arsc进行内容修改,来对apk进行深度压缩。这里可以采用微信的AndResGuard方案。


github.com/shwenzhang/…


8. 三方库优化


移除无用三方库

移除无用三方库需要人肉扫描 build.gradle 文件,一个一个的去检查依赖的三方库是否被我们代码所使用。


功能重复的三方库整合

特别常见的case,RN 用的图片加载库是 Fresco,客户端用的图片加载库是 Glide,他们都是用来加载图片,可以通过删除一个库,让项目依赖的库少一个。



  • 修改三方库源码,不需要的代码进行剔除


一个三方库往往不会被用到全部功能,比如曾经很火的 XUtils github.com/wyouflf/xUt…


XUtils是一个工具大杂烩,但是假如我只用它来加载图片,其他工具是不是就完全无用,可以进行剔除。


9. 去除 DebugItem 包含的 debug信息与行号信息


在讲解什么是 deubg 信息与行号信息之前,我们需要先了解 Dex 的一些知识。


我们都知道,JVM 运行时加载的是 .class 文件,而 Android 为了使包大小更加紧凑、运行时更加高效就发明了 Dalvik 和 ART 虚拟机,两种虚拟机运行的都是 .dex 文件,当然 ART 虚拟机还可以同时运行 oat 文件。


所以 Dex 文件里的信息内容和 Class 文件包含的信息是一样的,不同的是 Dex 文件对 Class 中的信息做了去重,一个 Dex 包含了很多的 Class 文件,并且在结构上有比较大的差异,Class 是流式的结构,Dex 是分区结构,Dex 内部的各个区块间通过 offset 来进行索引。


为了在应用出现问题时,我们能在调试的时候去显示相应的调试信息或者上报 crash 或者主动获取调用堆栈的时候能通过 debugItem 来获取对应的行号,我们都会在混淆配置中加上下面的规则:


-keepattributes SourceFile, LineNumberTable

这样就会保留 Dex 中的 debug 与行号信息。根据 Google 官方的数据,debugItem 一般占 Dex 的比例有 5% 左右


10. ReDex


ReDex 是 Facebook 开发的一个 Android 字节码的优化工具。它提供了 .dex 文件的读写和分析框架,并提供一组优化策略来提升字节码。官方提供预期优化效果:对dex文件优化为 8%


github.com/facebook/re…


11. R 文件瘦身


当 Android 应用程序被编译,会自动生成一个 R 类,其中包含了所有 res/ 目录下资源的 ID。包括布局文件layout,资源文件,图片(values下所有文件)等。在写java代码需要用这些资源的时候,你可以使用 R 类,通过子类+资源名或者直接使用资源 ID 来访问资源。R.java文件是活动的Java文件,如MainActivity.java的和资源如strings.xml之间的胶水


通过R文件常量内联,达到R文件瘦身的效果。


github.com/bytedance/B…


12. 可能的更多方案


除了我上面列到的一些,市面上还有一些其他的方案,有复杂的有不复杂的,有收益高的有收益低的,大家可以在掘金上搜索Android包体积优化,就能搜到大部分了,当然,在大厂里,还会有很多很极致的方案,比如:



  • 去掉kotlin生成的许多模板代码、判空代码

  • 去除布局文件里不需要的冗余内容

  • ...


思想是这么个思想,大家在实操的时候,思路就是先调研方案,调研完成之后再选型。


六、基于风险收益比及成本收益比敲定最终实现方案


这一步的重点是:明确风险收益比及成本收益比


方案与方案之间是有区别的。



  • 如果一个方案能减少包体积2M,但是线上可能会崩溃,你会做吗?

  • 如果一个方案能减少包体积2M,但是开发成本要一个月,你会做吗?


这里我们在示例App的基础上,对每个手段进行仔细分析,包括:



  1. 预期效果

  2. 成本

  3. 风险


就这样,当我们制定完成我们的目标方案之后,就可以放手干了。


手段预期效果成本是否要做进度备注
重点优化项
- 缩减RN 内置bundle预期效果:177.43M -> 114.43MRN 内置bundle缩减,xxxx版本带上
- 分架构打包,64位、32位分开打包预期效果:32位:117.43M -> 71.9M 64位:117.43M -> 87.6Mxxxx
- so压缩方案预期效果:32位:71.9M -> 55.5M 64位:87.6M -> 58.3Mxxxx
- 大so文件动态下发预期效果:32位:55.5M -> 50.7M 64位:58.3M -> 51.7Mxxxx
大文件优化
- zip优化,对内置的压缩文件替换压缩算法预期针对 assets 文件针对不同类型文件选取不同高压缩率算法
代码优化 (dex文件为 15.6M)
- 去除无用代码Android Lintxxx
- 减少ENUM的使用全部代码 enum类 一共60个,就算全删了也只是减少 84kxxx每减少一个ENUM可以减少大约1.0到1.4 KB的大小
资源优化 (目前res目录大小为 6.3M,emoji目录大小为 770k)
- 无用资源文件清理Android Lintxxx用ApkChecker再跑一次
- 图片压缩、转webpTinyPngxxx
- 图片着色器xxx
- 图片动态下发主要是针对比较大的图,实际上经过TinyPng 压缩后,图片大小已经大大减小xxx
- resources.arsc资源混淆AndResGuard两年不维护,花了一小时没完全跑起来,但看到了大致优化效果,1.3M -> 920kgithub.com/shwenzhang/…
三方库优化 (dex文件为 15.6M)
- 移除无用三方库检查一下
- 移除无用三方so库
- 功能重复三方库整合
- 修改三方库源码,不需要的代码进行剔除
极致优化,附 ByteX 插件情况
- 去除 DebugItem 包含的 debug信息与行号信息mp.weixin.qq.com/s/_gnT2kjqp…
- ReDex对dex文件优化为 8%,即在当前dex总和 15.6M的基础上,可以减少 1.2MDex 压缩,首次启动解压dexhttps://github.com/facebook/redexhttps://juejin.cn/post/7104228637594877965
- R 文件瘦身现成方案:github.com/bytedance/B… failed for task ':app:transformClassesWithShrinkRFileForQaRelease'.> java.lang.RuntimeException: This feature requires ASM7

七、确定优化效果


当我们进行了一系列的或大或小的改动之后,如何描述最终优化效果?给两张对比图不就行了,无图言X。




八、总结


大家在进行一些有挑战性或者是比较有意义的项目时,其实可以多进行总结,总结的好处有什么我就不多解释了,懂的都懂哈。


比如我们这里可以装模作样的这样总结一下:


做的好的方面



  1. 足够系统化

  2. 前置调研足够充分

  3. 风险、收益、成本考虑足够充分

  4. 各方面沟通足够充分

  5. 优化决心足够大


也可以告诉自己及读者几句话



  1. 这是一个系统的需要持续去投入人力的事情,万万不可有了一定结果之后放松警惕

  2. 别人能做的,我们也能做,只要有足够的决心去做

  3. 做事不能太讲究所谓的方法论,不然会掉入陷阱,但是确实要讲究方法论

  4. 有些事情你做好了,可能仅仅是因为做这个事情的人是你,如果是别人来做,也能将这件事情做好


九、展望


一般来说,进行总结之后,都得来一些展望,给未来的自己挖点坑,给总结的读者画点饼。比如我们这里就可以这样继续装模作样的展望一下:


上面已经反复提及了,当前这一期的优化工作,重点考量的指标是风险收益比及成本收益比,所以一些极致的或者成本收益比较高的优化手段并没有被采用,所以后续还是有很多事情可以深入的干下去。



  1. resources.arsc资源混淆

  2. 去除 DebugItem 包含的 debug信息与行号信息

  3. ReDex

  4. R 文件瘦身

  5. So unwind 优化

  6. kotlin相关优化

  7. ...


十、真正的总结


这里我就发散性的随便总结下吧。。。也不深入纠结了。



  1. 包体积优化是个庞大的工程项目,不仅仅需要优化,还需要防劣化,优化过程中还会涉及到业务冲突,说白了就是某些东西从APK包中移除了,或多或少会有些影响,还需要去跟业务方达成意见一致。

  2. 大家不管在做什么优化课题时,最好是分步骤分工期的去进行,不要一口吃成胖子,如果上来就追求完全极致的优化效果,往往会带来两个负面风险:1)  优化工期拉长,时间成本成倍增加,2)可能影响线上App或者线下各种工具的运行稳定性。

  3. 系统化的调研、成本 + 风险 + 收益的总和考虑非常重要,任何优化项目开始进行或者进行过程中,都需要牢牢的印在脑子里,每日三省你身。

  4. 遇到困难不要畏惧,各种优化项目往往会遇到很多阻力,比如方案实现太难、业务沟通太难等等,一块石头硬磕磕不动的时候换个方向磕,换方向也磕不动那就换块石头磕,比如假设业务方沟通不动,那就换个角度,把你和业务方放在同一角色上,给业务方找收益或者。

  5. 做的项目是啥或者说研究的方向是啥其实不是最重要的,我们这种普通程序员更重要的是解决问题的能力,因为你们做的事情,换个人用同样的时间成本或者更多的时间成本,往往也能做好,所以是你做好的这件事情其实没那么重要,更重要的是遇到其他问题或者有其他的疑难杂症和系统性问题时,知道你一定能做好。


作者:邹阿涛涛涛涛涛涛
来源:juejin.cn/post/7302982924987039796
收起阅读 »

HarmonyOS 页面传值跳转

介绍 本篇主要介绍如何在HarmonyOS中,在页面跳转之间如何传值 HarmonyOS 的页面指的是带有@Entry装饰器的文件,其不能独自存在,必须依赖UIAbility这样的组件容器 如下是官方关于State模型开发模式下的应用包结构示意图,Page就是...
继续阅读 »

介绍


本篇主要介绍如何在HarmonyOS中,在页面跳转之间如何传值


HarmonyOS 的页面指的是带有@Entry装饰器的文件,其不能独自存在,必须依赖UIAbility这样的组件容器


如下是官方关于State模型开发模式下的应用包结构示意图,Page就是带有@Entry装饰器的文件


0000000000011111111.20231123162458.56374277887047155204379708661912.png


那么在页面跳转时,在代码层面最长路径其实是有两步 1,打开UIAbility 2. 打开Page


整体交互效果


页面传值demo.png


传值理论



  1. 基于LocalStorage

  2. 基于EventHub

  3. 基于router


准备


请参照官方指导,创建一个Demo工程,选择Stage模型


代码实践


1.定制主入口页面


功能



  1. 页面曝光停留时长计算

  2. 增加进入二级页面入口


import systemDateTime from '@ohos.systemDateTime'
import router from '@ohos.router'

@Entry
@Component
struct Index {
@State message: string = '页面跳转'

private showDuration: number = 0

onPageShow() {

this.showDuration = 0
systemDateTime.getCurrentTime(false, (error, data) => {
if(!error){
this.showDuration = data
}
})

}

build() {
Row() {
Column() {
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(()=>{
systemDateTime.getCurrentTime(false, (error, data) => {
router.pushUrl({ url: 'pages/OpenPage', params: {
"from": "pages/Home.ets",
"data": {
"duration":(data - this.showDuration)
}
} })
.then(() => {
console.info('Succeeded in jumping to the second page.')
}).catch((error) => {
console.log(error)
})
})
})
}
.width('100%')
}
.height('100%')
}

}

2.添加二级页面


注意

OpenPage.ets需要在main_pages.json中的注册


{
"src": [
"pages/Index" //主入口页面
,"pages/OpenPage" //二级页面
,"pages/Test" //三级页面
,"pages/LocalStorageAbilityPage" //三级页面
]
}

功能



  1. 展示主入口页面停留时间

  2. 添加通过UIAbility方式打开页面的入口

  3. 添加通过router.pushUrl方式打开页面的入口


/**
* 路由 3.1/4.0 文档
* https://developer.harmonyos.com/cn/docs/documentation/doc-references-V3/js-apis-router-0000001478061893-V3#ZH-CN_TOPIC_0000001523808578__routerpushurl9
*
*/

import router from '@ohos.router';
import common from '@ohos.app.ability.common';


@Entry
@Component
struct OpenPageIndex{
@State extParams: string = ''
private expParamsO: Object
private context = getContext(this) as common.UIAbilityContext;

aboutToAppear(){
this.expParamsO = router.getParams();
this.extParams = JSON.stringify(this.expParamsO, null, '\t');
}

build(){
Column(){

List(){
ListItemGr0up() {
ListItem() {
Text(this.extParams)
.width('96%')
.fontSize(18)
.fontColor(Color.Green)
.backgroundColor(Color.White)
}.width('100%')
.align(Alignment.Start)
.backgroundColor(0xFFFFFF)
.borderRadius('16vp')
.padding('12vp')

}.divider({
strokeWidth: 1,
startMargin: 0,
endMargin: 0,
color: '#ffe5e5e5'
})

ListItemGr0up() {

ListItem() {
Text('启动UIAbility页面')
.width('96%')
.fontSize(18)
.fontColor(Color.Black)
.backgroundColor(Color.White)
}.width('100%')
.height(50)
.align(Alignment.Start)
.backgroundColor(0xFFFFFF)
.padding({ left: 10 })
.onClick(() => {
this.startAbilityTest('LocalStorageAbility')
})

ListItem() {
Text('启动@Entry页面')
.width('96%')
.fontSize(18)
.fontColor(Color.Black)
.backgroundColor(Color.White)
}.width('100%')
.height(50)
.align(Alignment.Start)
.backgroundColor(0xFFFFFF)
.padding({ left: 10 })
.onClick(() => {
router.pushUrl({ url: 'pages/Test', params: {
"from": "pages/OpenPage.ets"
} })
.then(() => {
console.info('Succeeded in jumping to the second page.')
}).catch((error) => {
console.log(error)
})
})

}.divider({
strokeWidth: 1,
startMargin: 0,
endMargin: 0,
color: '#ffe5e5e5'
})

}.width('100%').height('90%')
.divider({
strokeWidth: px2vp(20),
startMargin: 0,
endMargin: 0,
color: '#ffe5e5e5'
})

}.width('100%').height('100%')
.padding({ top: px2vp(111) , left: '12vp', right: '12vp'})
.backgroundColor('#ffe5e5e5')
}

async startAbilityTest(name: string) {
try {
let want = {
deviceId: '', // deviceId为空表示本设备
bundleName: 'com.harvey.testharmony',
abilityName: name,
parameters:{
from: 'OpenPage.ets',
data: {
hello: 'word',
who: 'please'
}
}
};
let context = getContext(this) as common.UIAbilityContext;
await context.startAbility(want);
console.info(`explicit start ability succeed`);
} catch (error) {
console.info(`explicit start ability failed with ${error.code}`);
}

}

}


3. 添加三级页面


注意

先要添加注册一个新的容器,这里命名为:LocalStorageAbility.ets
容器需要在module.json5中声明


  {
"name": "LocalStorageAbility",
"srcEntry": "./ets/entryability/LocalStorageAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:icon",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:icon",
"startWindowBackground": "$color:start_window_background"
}

import window from '@ohos.window';
import UIAbility from '@ohos.app.ability.UIAbility';


let para:Record<string,string> = { 'PropA': JSON.stringify({ 'from': 'LocalStorageAbility'}) };
let localStorage: LocalStorage = new LocalStorage(para);

export default class LocalStorageAbility extends UIAbility {

storage: LocalStorage = localStorage

onCreate(want, launchParam) {

}

onWindowStageCreate(windowStage: window.WindowStage) {
super.onWindowStageCreate(windowStage)

windowStage.loadContent('pages/LocalStorageAbilityPage', this.storage, (err, data) => {
if (err.code) {
return;
}

setTimeout(()=>{
let eventhub = this.context.eventHub;
console.log(para['PropA'])
eventhub.emit('parameters', para['PropA']);
}, 0)

});
}

}

Test.ets和LocalStorageAbilityPage.ets需要在main_pages.json中的注册


{
"src": [
"pages/Index" //主入口页面
,"pages/OpenPage" //二级页面
,"pages/Test" //三级页面
,"pages/LocalStorageAbilityPage" //三级页面
]
}

功能



  1. 展示基于LocalStorage,EventHub,router 三种传值方式的数据


LocalStorageAbilityPage.ets 文件



  • 展示LocalStorage,EventHub方式的数据


import router from '@ohos.router';
import common from '@ohos.app.ability.common';

// 通过GetShared接口获取stage共享的LocalStorage实例
let storage = LocalStorage.GetShared()

@Entry(storage)
@Component
struct LocalStorageAbilityPageIndex {
@State message: string = ''
// can access LocalStorage instance using
// @LocalStorageLink/Prop decorated variables
@LocalStorageLink('PropA') extLocalStorageParms: string = '';

context = getContext(this) as common.UIAbilityContext;

aboutToAppear(){
this.eventHubFunc()
}

build() {
Row() {
Column({space: 50}) {

Column({space: 10}){
Text('LocalStorage传值内容')
Text(JSON.stringify(JSON.parse(this.extLocalStorageParms), null, '\t'))
.fontSize(18)
.fontColor(Color.Green)
.backgroundColor(Color.White)
.width('100%')
.padding('12vp')
.borderRadius('16vp')
}

Column({space: 10}){
Text('eventHub传值内容')
Text(this.message)
.fontSize(18)
.fontColor(Color.Green)
.backgroundColor(Color.White)
.width('100%')
.padding('12vp')
.borderRadius('16vp')
}

}.width('100%').height('100%')
.padding({ top: px2vp(111) , left: '12vp', right: '12vp'})
.backgroundColor('#ffe5e5e5')
}
.height('100%')

}

eventHubFunc() {
this.context.eventHub.on('parameters', (...data) => {
this.message = JSON.stringify(JSON.parse(data[0]), null, '\t')
});
}

}

作者:harvey_fly
来源:juejin.cn/post/7306447457151942690
收起阅读 »

android之阿拉伯语适配及注意细节

1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。若未增加该元素,在xml中切换语言时,...
继续阅读 »

1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。

若未增加该元素,在xml中切换语言时,会提示 image.png 增加后,可在xml文件中查看反转后的效果 2.  新增value-ar文件夹

image.png

image.png

image.png 把values/strings.xml文件复制到values-ar文件中,逐条翻译即可。

  1. layout中的Left/Right修改为Start/End

可使用Android Studio中自带的工具:“工具栏”-“Refactor”-“Add right-to-Left(RTL)Support” image.png

注意事项:

  • 1).此时会把所依赖gradle里的xml文件列出,记得删除,不要转换。

image.png

  • 2). 该工具只适用于项目的app模块,无法直接应用于依赖模块。如果需要在依赖模块中进行RTL转换,要逐个打开并手动进行相应的修改。
  • 3). Start属性在LTR中对应Left,End属性在LTR中对应Right,在API 17开始支持,为了兼容低版本,可以同时有Left和Start。

    即在“Add right-to-Left(RTL)Support”工具中,不勾选“Replace Left/Right Properties with Start/End Properties”

image.png

  1. 返回icon、下一个icon等,要针对阿拉伯语新建一个文件夹,放镜像后的图片,规则如下:

mipmap-xhdpi->mipmap-ldrtl-xhdpi

drawable->drawable-ldrtl

最终镜像的图片要UI同事提供,临时修改看效果可以使用镜像图片的网站:http://www.lddgo.net/image/flip

  1. TextView、EditText:利用全局样式,在style.xml中定义,在xml里使用style=”@style/xxx”即可
  • 1). TextView
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
       ...
       <item name="android:textViewStyle">@style/TextViewStyle.TextDirectionitem>
       ...
style>
<style name="TextViewStyle.TextDirection" parent="android:Widget.TextView">
        <item name="android:textDirection">localeitem>
style>
  • 2). EditText
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
       ...
       <item name="editTextStyle">@style/EditTextStyle.Alignmentitem>
       ...
style>
<style name="EditTextStyle.Alignment" parent="@android:style/Widget.EditText">
        <item name="android:textAlignment">viewStartitem>
        <item name="android:gravity">startitem>
        <item name="android:textDirection">localeitem>
style>
  1. 其他细节
  • 1).固定ltr,如阿拉伯语下的“99%”要从左到右展示,可在xml中使用
android:layoutDirection ="ltr"
  • 2).获取当前系统语言Locale.getDefault().getLanguage()

判断是否为阿拉伯语:"ar".equals(Locale.getDefault().getLanguage())

判断是否为英语:"en".equals(Locale.getDefault().getLanguage())

  • 3). drawable/xxx_selector.xml中item里有android:drawable,如勾选框。

drawable有android:autoMirrored属性,将selector的该属性设置为true,就可以让drawable在RTL布局下进行反转

image.png

  • 4).进度条的默认进度指示是从左到右,使用leftMargin;在阿拉伯语下,进度指示从右到左,使用rightMargin属性
  • 5).阿拉伯语环境下,使用SimpleDateFormat格式化时间字符串的时候,会显示为:٢٠١٥-٠٩-١٨ ٠٧:٠٣:٤٩。若要展示:2023-09-067:10:45,可以使用Locale.ENGLISH参数
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
Date now=new Date();
System.out.println(sdf .format(now));
  • 6). 加载html可用 tv.setText(Html.fromHtml(getResources().getString(R.String.xxx));
  • 7). 开机导航中设置了阿拉伯语,当前页面布局要刷新,可以重写activity的onConfigurationChanged()方法,如在该方法里重置下一步箭头、指示器样式等

image.png

  • 8).ViewPager

若是ViewPager,可使用第三方控件RtlViewPager替换: 521github.com/diego-gomez…,添加依赖,单纯替换原ViewPager即可

implementation 'com.booking:rtlviewpager:1.0.1' 

类似三方控件: 521github.com/duolingo/rt…

或者使用androidx的ViewPager2替换: developer.android.google.cn/jetpack/and…,支持RTL布局

image.png

image.png

  • 9). 固定RTL字符串的顺序

问题现象:EditText带hint,密码可见、不可见时,会调用如下方法进行设置

image.png 此时会影响hint的展示:在勾选时,hint的结束字符在右侧;不勾选时,hint的结束字符在左侧。

image.png

image.png

解决方法:此时要使用Unicode控制字符来限制整个字符串的显示方向:\u202B 和 \u202C。

image.png

有以下两种方法

a.  java代码

image.png

b.  strings.xml

image.png

最终效果:

image.png

image.png

10). Blankj的toast展示异常

android工具类Blankj的toast工具类在展示阿拉伯语时为空或者部分展示,建议使用1.30.6 及以上版本

image.png

github.com/Blankj/Andr…

11). RTL布局中出现双光标/光标截断的情形

image.png

在布局文件内加上如下两个属性即可:

android:textDirection="anyRtl"
android:textAlignment="viewStart"

若还未解决

1.可查看是否使用了android:textCursorDrawable=“@null”,若有,可尝试去掉该句。

2.在AndroidManifest.xml中查看当前App/Activity的主题,比较老的项目可能使用了android:Theme.NotitleBar/android:Theme.Light等轻量级主题,如下所示:




收起阅读 »

得物App安卓冷启动优化-Application篇

前言 冷启动指标是App体验中相当重要的指标,在电商App中更是对用户的留存意愿有着举足轻重的影响。通常是指App进程启动到首页首帧出现的耗时,但是在用户体验的角度来看,应当是从用户点击App图标,到首页内容完全展示结束。 将启动阶段工作分配为任务并构造出有向...
继续阅读 »

前言


冷启动指标是App体验中相当重要的指标,在电商App中更是对用户的留存意愿有着举足轻重的影响。通常是指App进程启动到首页首帧出现的耗时,但是在用户体验的角度来看,应当是从用户点击App图标,到首页内容完全展示结束。


将启动阶段工作分配为任务并构造出有向无环图的设计已经是现阶段组件化App的启动框架标配,但是受限于移动端的性能瓶颈,高并发度的设计使用不当往往会让锁竞争、磁盘IO阻塞等耗时问题频繁出现。如何百尺竿头更进一步,在启动阶段有限的时间里,将有限的资源最大化利用,在保障业务功能稳定的前提下尽可能压缩主线程耗时,是本文将要探讨的主题。


本文将介绍我们是如何通过对启动阶段的系统资源做统一管控,按需分配和错峰加载等手段将得物App的线上启动指标降低10%,线下指标降低34%,并在同类型的电商App中提升至Top3


一、指标选择


传统的性能监控指标,通常是以Application的attachBaseContext回调作为起点,首页decorView.postDraw任务执行作为结束时间点,但是这样并不能统计到dex加载以及contentProvider初始化的耗时。


因此为了更贴近用户真实体验,在启动速度监控指标的基础上,我们添加了一个线下的用户体感指标,通过对录屏文件逐帧分析,找到App图标点击动画开始播放(图标变暗)作为起始帧,首页内容出现的第一帧作为结束帧,计算出结果作为启动耗时。


例:启动过程为03:00 - 03:88,故启动耗时为880ms。


1.png


二、Application优化


App在不同的业务场景下可能会落到不同的首页(社区/交易/H5),但是Application运行的流程基本是固定的,且很少变更,因此Application优化是我们的首要选择。


得物App的启动框架任务在近几年已经先后做过多轮优化,常规的抓trace寻找耗时点并异步化已经不能带来明显的收益,得从锁竞争,CPU利用率的角度去挖掘优化点,这类优化可能短期收益不会特别明显,但从长远来看能够提前规避很多劣化问题。


1.WebView优化


App在首次调用webview的构造方法时会拉起系统对webview的初始化流程,一般会耗时200+ms,如此耗时的任务常规思路都是直接丢到子线程去执行,但是chrome内核中加入了非常多的线程检查,使得webview只能在构造它的线程中使用。


01.png


为了加速H5页面的启动,App通常会选择在Application阶段就初始化webview并缓存,但是webview的初始化涉及跨进程交互和读文件,因此CPU时间片,磁盘资源和binder线程池中任何一种不足都会导致其耗时膨胀,而Application阶段任务繁多,恰恰很容易出现以上资源短缺的情况。


02.png


因此我们将webview拆分成三个步骤,分散到启动的不同阶段来执行,这样可以降低因为竞争资源导致的耗时膨胀问题,同时还可以大幅度降低出现ANR的几率。


04.png


1.1 任务拆分


a. provider预加载


WebViewFactoryProvider是用于和webview渲染进程交互的接口类,webview初始化的第一步就是加载系统webview的apk文件,构建出classloader并反射创建了WebViewFactoryProvider的静态实例,这一操作并没有涉及线程检查,因此我们可以直接将其交给子线程执行。


10.png


b. 初始化webview渲染进程


这一步对应着chrome内核中的WebViewChromiumAwInit.ensureChromiumStartedLocked()方法,是webview初始化最耗时的部分,但是和第三步是连续执行的。走码分析发现WebViewFactoryProvider暴露给应用的接口中,getStatics这个方法会正好会触发ensureChromiumStartedLocked方法。


至此,我们就可以通过执行WebSettings.getDefaultUserAgent()来达到仅初始化webview渲染进程的目的。


150.png


c. 构造webview


即new Webview()


1.2 任务分配


为了最大程度缩短主线程耗时,我们的任务安排如下:



  • a. provider预加载,可以异步执行,且没有任何前置依赖,因此放在Application阶段最早的时间点异步执行即可。

  • b. 初始化webview渲染进程,必须在主线程,因此放到首页首帧结束之后。

  • c. 构造webview,必须在主线程,在第二步完成时post到主线程执行。这样可以确保和第二步不在同一个消息中,降低ANR的几率。


160.png


1.3 小结


尽管我们已经将webview初始化拆分为了三个部分,但是耗时占比最高的第二步在低端机或者极端情况还是可能触达ANR的阈值,因此我们做了一些限制,例如当前设备会统计并记录webview完整初始化的耗时,仅当耗时低于配置下发的阈值时,开启上述的分段执行优化。


App如果是通过推送、投放等渠道打开,一般打开的页面大概率是H5营销页,因此这类场景不适用于上述的分段加载,所以需要hook主线程的messageQueue,解析出启动页面的intent信息,再做判断。


受限于开屏广告功能,我们目前只能对无开屏广告的启动场景开启此优化,后续将计划利用广告倒计时的间隙执行步骤2,来覆盖有开屏广告的场景。


170.png


2.ARouter优化


在当下组件化流行的时代,路由组件已经几乎是所有大型安卓App必备的基础组件,目前得物使用的是开源的ARouter框架。


ARouter 框架的设计是它默认会将注解中注册path路径中第一个路由层级 (例如 "/trade/homePage"中的trade)作为该路由信息所的Gr0up, 相同Gr0up路径的路由信息会合并到最终生成的同一个类 的注册函数中进行同步注册。在大型项目中,对于复杂业务线同一个Gr0up下可能包含上百个注册信息,注册逻辑执行过程耗时较长,以得物为例,路由最多的业务线在初始化路由上的耗时已经来到了150+ms。


190.png


路由的注册逻辑本身是懒加载的,即对应Gr0up之下的首个路由组件被调用时会触发路由注册操作。然而ARouter通过SPI(服务发现)机制来帮助业务组件对外暴露一些接口,这样不需要依赖业务组件就可以调用一些业务层的视线,在开发这些服务时,开发者一般会习惯性的按照其所属的组件为其设置路由path,这使得首次构造这些服务的时候也会触发同一个Gr0up下的路由加载。


而在Application阶段肯定需要用到业务模块的服务中的一些接口,这就会提前触发路由注册操作,虽然这一操作可以在异步线程执行,但是Application阶段的绝大部分工作都需要访问这些服务,所以当这些服务在首次构造的耗时增大时,整体的启动耗时势必会随之增长。


2.1 ARouter Service路由分离


ARouter采用SPI设计的本意是为了解耦,Service的作用也应该只是提供接口,所以应当新增一个空实现的Service专门用于触发路由加载,而原先的Service则需要更换一个Gr0up,后续只用于提供接口,如此一来Application阶段的其他任务就不需要等待路由加载任务的完成。


001.png


2.2 ARouter支持并发装载路由


我们在实现了路由分离之后,发现现有的热点路由装载耗时总和是大于Application耗时,而为了保证在进入闪屏页之前完成对路由的加载,主线程不得不sleep等待路由装载完毕。


分析可知ARouter的路由装载方法加了类锁,因为他需要将路由装载到仓库类中的map,这些map是线程不安全的HashMap,相当于所有的路由装载操作其实都是在串行执行,而且存在锁竞争的情况,最终导致耗时累加大于Application耗时。


002.png


分析trace可知耗时主要来自频繁调用装载路由的loadInto操作,再分析这里锁的作用,可知加类锁是主要是为了确保对仓库WareHouse中map操作的线程安全。


003.png


因此我们可以将类锁降级对Gr0upMeta这个class对象加锁(这个class是ARouter apt生成的类,对应apk中的ARouterProviderProviderxxx类),来确保路由装载过程中的线程安全,至于在此之前对map操作的线程安全问题,则完全可以通过将这些map替换为concurrentHashMap解决,在极端并发情况下会有一些线程安全问题,也可以按照图中添加判空来解决。


009.png


010.png


至此,我们就实现了路由的并发装载,随后我们根据木桶效应对要预载的service进行合理分组,再放到协程中并发执行,确保最终整体耗时最短。


011.png


012.png


3.锁优化


Application阶段执行的任务多为基础SDK的初始化,其运行的逻辑通常相对独立,但是SDK之间会有依赖关系(例如埋点库会依赖于网络库),且大部分都会涉及读文件,加载so库等操作,Application阶段为了压缩主线程的耗时,会尽可能地将耗时操作放到子线程中并发运行,充分利用CPU时间片,但是这也不可避免的会导致一些锁竞争的问题。


3.1 Load so锁


System.loadLibrary()方法用于加载当前apk中的so库,这个方法对Runtime对象加了锁,相当于一个类锁。


基础SDK在设计上通常会将load so的操作写到类的静态代码块中,确保在SDK初始化代码执行之前就准备好了so库。如果这个基础SDK恰巧是网络库这类基础库,会被很多其他SDK调用,就会出现多个线程同时竞争这个锁的情况。那么在最坏的情况下,此时IO资源紧张,读so文件变慢,并且主线程是锁等待队列中最后一个,那么启动耗时将远超预期。


034.png


为此,我们需要将loadSo的操作统一管控并收敛到一个线程中执行,强制他们以串行的方式运行,这样就可以避免以上情况的出现。值得一提的是,前面webview的provider预加载的过程中也会加载webview.apk中的so文件,因此需要确保preloadProvider的操作也放到这个线程。


so的加载操作会触发native层的JNI_onload方法,一些so可能会在其中执行一些初始化工作,因此我们不能直接调用System.loadLibrary()方法来进行so加载,否则可能会重复初始化出现问题。


我们最终采用了类加载的方式,即将这些so加载的代码全部挪到相关类的静态代码块中,然后再去触发这些类的加载即可,利用类加载的机制确保这些so的加载操作不会重复执行,同时这些类加载的顺序也要按照这些so使用的顺序来编排。


78.png


除此之外,so的加载任务不建议和其他需要IO资源的任务并发执行,在得物App中实测这两种情况下该任务的耗时相差巨大。


4.启动框架优化


目前常见的启动框架设计是将启动阶段的工作分配到一组任务节点中,再由这些任务节点的依赖关系构造出一个有向无环图,但是随着业务迭代,一些历史遗留的任务依赖已经没有存在的必要,但是他会拖累整体的启动速度。


启动阶段大部分工作都是基础SDK的初始化,他们之间往往有着复杂的依赖关系,而我们在做启动优化时为了压缩主线程的耗时,通常都会找出主线程的耗时任务并丢到子线程去执行,但是在依赖关系复杂的Application阶段,如果只是将其丢到异步执行未必能有预期的收益。


99.png


我们在做完webview优化之后发现启动耗时并没有和预期一样直接减少了webview初始化的耗时,而是只有预期的一半左右,经分析发现我们的主线程任务依赖着子线程的任务,所以当子线程任务没有执行完时,主线程会sleep等待。


并且webview之所以放在这个时间点初始化不是因为有依赖限制这它,而是因为这段时间主线程正好有一段比较长的sleep时间可以利用起来,但是异步的任务工作量是远大于主线程的,即便是七个子线程并发在跑,其耗时也是大于主线程的任务。


因此想进一步扩大收益,就得对启动框架中的任务依赖关系做优化。


66.png


671.jpeg


以上第一张图为优化之前得物App启动阶段任务的有向无环图,红框表示该任务在主线程执行。我们着重关注阻塞主线程任务执行的任务。


可以观察到主线程任务的依赖链路上存在几个出口和入口特别多的任务,出口多表明这类任务通常是非常重要的基础库(例如图中的网络库),而入口多表明这个任务的前置依赖太多,他开始执行的时间点波动较大。这两点结合起来就说明这个任务执行结束的时间点很不稳定,并且将直接影响到后续主线程的任务。


这类任务优化的思路主要是:



  • 拆解任务自身,将可以提前执行或者延后执行的操作分出去,但是分出去之前要考虑到对应的时间段还有没有时间片余量,或者会不会加重IO资源竞争的情况出现;

  • 优化该任务的前置任务,让该任务执行结束的时间点尽可能提早,就可以降低后续任务等待该任务的耗时;

  • 移除非必要的依赖关系,例如埋点库初始化只是需要注册一个监听器到网络库,并非发起网络请求。(推荐)


可以看到我们在优化之后的第二张有向无环图里,任务的依赖层级明显变少,入口和出口特别多的任务也都基本不再出现。


044.png


320.png


对比优化前后的trace,也可以看到子线程的任务并发度明显提高,但是任务并发度并不是越高越好,在时间片本身就不足的低端机上并发度越高表现可能会越差,因为更容易出锁竞争,IO等待之类的问题,因此要适当留下一定空隙,并在中低端机上进行充分的性能测试之后再上线,或者针对高中低端机器使用不同的任务编排。


三、首页优化


1.通用布局耗时优化


系统解析布局是通过inflate方法读取布局xml文件并解析构建出view树,这一过程涉及IO操作,很容易受到设备状态影响,因此我们可以在编译期通过apt解析布局文件生成对应的view构建类。然后在运行时提前异步执行这些类的方法来构建并组装好view树,这样可以直接优化掉页面inflate的耗时。


601.png


602.png


2.消息调度优化


在启动阶段我们通常会注册一些ActivityLifecycleListener来监听页面生命周期,或者是往主线程post了一些延时任务,如果这些任务中有耗时操作,将会影响到启动速度,因此可以通过hook主线程的消息队列,将页面生命周期回调和页面绘制相关的msg移动到消息队列的队头,这样就可以加快首页首帧内容展示的速度。


102.png


详情可期待本系列后续内容。


四、稳定性


性能优化对App只能算作锦上添花,稳定性才是生命红线,而启动优化改造的又都是执行时机非常早的Application阶段,稳定性风险程度非常高,因此务必要在准备好崩溃防护的前提下做优化,即便有不可避免的稳定性问题,也要将负面影响降到最低。


1.崩溃防护


由于启动阶段执行的任务都是重要的基础库初始化,因此发生崩溃时将异常识别并吃掉的意义不大,因为大概率会导致后续崩溃或功能异常,因此我们主要的防护工作都是发生问题之后的止血


配置中心SDK的设计通常都是从本地文件中读出缓存的配置使用,待接口请求成功后再刷新。所以如果当启动阶段命中了配置之后发生了crash,是拉不到新配置的。这种情况下只能清空App缓存或者卸载重装,会造成非常严重的用户流失。


109.png
崩溃回退


对所有改动点加上try-catch保护,捕捉到异常之后上报埋点并往MMKV中写入崩溃标记位,这样该设备在当前版本下都不会再开启启动优化相关的变更,随后再抛出原异常让他崩溃掉。至于native crash则是在Crash监控的native崩溃回调里执行同样操作即可。


1100.png
运行状态检测


Java Crash我们可以通过注册unCaughtExceptionHandler来捕捉到,但是native crash则需要借助crash监控SDK来捕捉,但是crash监控未必能在启动最早的时间点初始化,例如Webview的Provider的预加载,以及so库的预加载都是早于crash监控,而这些操作都涉及native层的代码。


为了规避这种场景下的崩溃风险,我们可以在Application的起始点埋入MMKV标记位,在结束点改为另一个状态,这样一些执行时间早于配置中心的代码就可以通过获取这个标记位来判断上一次运行是否正常,如果上次启动发生了一些未知的崩溃(例如发生在crash监控初始化之前的native崩溃),那么通过这个标记位就可以及时关闭掉启动优化的变更。


结合崩溃之后自动重启的操作,在用户视角其实是观察不到闪退的,只是会感觉到启动的耗时约是平时的1-2倍。


0456.png
配置有效期


线上的技改变更通常都会配置采样率,结合随机数实现逐渐放量,但是配置下发SDK的设计通常都是默认取上次的本地缓存,在发生线上崩溃等故障时,尽管及时回滚了配置,但是缓存的设计会导致用户还会因为缓存遭遇至少一次的崩溃。


为此,我们可以为每一个开关配置加一个配套的过期时间戳,限制当前放量的开关只在该时间戳之前生效,这样在遇到线上崩溃等故障时确保可以及时止血,而且时间戳的设计也可以避免线上配置生效的滞后性导致的crash。


457.png


用户视角下,添加配置有效期前后对比:


678.jpeg


五、总结


至此,我们已经对安卓App中比较通用的冷启动耗时案例做了分析,但是启动优化最大的痛点往往还是App自身的业务代码,应当结合业务需求合理的进行任务分配,如果一味的靠预加载,延迟加载和异步加载是不能从根本上解决耗时问题的,因为耗时并没有消失只是转移,随之而来的可能是低端机启动劣化或功能异常。


做性能优化不仅需要站在用户的视角,还要有全局观,如果因为启动指标算是首页首帧结束就把耗时任务都丢到首帧之后,势必会造成用户后续的体验有卡顿甚至ANR。所以在拆分任务时不仅需要考虑是否会和与其并发的任务竞争资源,还需要考虑启动各个阶段以及启动后一段时间内的功能稳定性和性能是否会受之影响,并且需要在高中低端机器上都验证下,至少要确保都没有劣化的表现。


1.防劣化


启动优化绝不是一次性的工作,它需要长时间的维护和打磨,基础库的一次技改可能就会让指标一夜回到解放前,因此防劣化必须要尽早落地。


通过在关键点添加埋点,可以做到在发现线上指标劣化时迅速定位到劣化代码大概位置(例如xxActivity的onCreate)并告警,这样不仅可以帮助研发迅速定位问题,还可以避免线上特定场景指标劣化线下无法复现的情况,因为单次启动的耗时波动范围最高能有20%,如果直接去抓trace分析可能连劣化的大概范围都难以定位。


例如两次启动做trace对比时,其中一次因为遇到IO阻塞导致某次读文件的操作都明显变慢,而另一次IO正常,这就会误导开发者去分析这些正常的代码,而实际导致劣化的代码可能因为波动正好被掩盖。


2.展望


对于通过点击图标启动的普通场景,默认会在Application执行完整的初始化工作,但是一些层级比较深的功能,例如客服中心,编辑收货地址这类,即使用户以最快速度直接进入这些页面,也是需要至少1s以上的操作时间,所以这些功能相关的初始化工作也是可以推迟到Application之后的,甚至改为懒加载,视具体功能的重要性而定。


通过投放,push来做召回/拉新的启动场景通常占比较少,但是其业务价值要远大于普通场景。由于目前启动耗时主要来源于webview初始化以及一些首页预载相关的任务,如果启动落地页并不需要所有基础库(例如H5页面),那么这些我们就可以将它不需要的任务统统延迟加载,这样启动速度可以得到大幅度增长,做到真正意义上的秒开。


*文/Jordas


作者:得物技术
来源:juejin.cn/post/7306447634204770319
收起阅读 »

突发:鸿蒙之祖华为在 openInula 官网声称可“避免重复运行组件”但网友挖出“组件渲染次数”是写死的

消息来源 看到群里这个消息,我的想法是:群里怎么炸锅了?华为之前的鸿蒙被指疑似安卓二开不说,现在出个 openInula 好像是什么欧拉又被人挖出幺蛾子?哦有 la 后缀但好像又不像欧拉。 到底怎么回事?我也不敢说话甚至都不敢参与讨论。 求真过程 不过华为好...
继续阅读 »


消息来源



看到群里这个消息,我的想法是:群里怎么炸锅了?华为之前的鸿蒙被指疑似安卓二开不说,现在出个 openInula 好像是什么欧拉又被人挖出幺蛾子?哦有 la 后缀但好像又不像欧拉。


到底怎么回事?我也不敢说话甚至都不敢参与讨论。


求真过程


不过华为好不好是个大企业,并且又是风口浪尖一样的存在,褒贬两级分化。真,搞得我一直到现在我都不知道遥遥领先到底是一个什么词语,时常怀疑我自己是不是出轨了。


官网现象


我打开 openInula 的官网 http://www.openinula.net/ ,看样子还是很高大尚的。


Alt text


上来就是 相比传统虚拟DOM方式,提升渲染效率30%以上。完全兼容React API,支持React应用无缝切换至openInula。 这种兼容传统但吊打传统的描述,很难让人不把他是电、他是光、他是唯一的希望等联想在一起。


继续向下:我们看网友说的造假现象到底是不是存在。


我小心翼翼的向下滑动页面,目不转睛的注视着每一个窗口,全神贯注的查找目标文字组件渲染次数,内心忐忑不安的希望这不是真的。


但是,现实总是这么残酷,左右两栏的对比里,左边的数据是动态的,右边为什么就要写死?难道页面在跳动开发测试都看不出来吗?为了避免是我的错觉问题,我决定对其 GIF 录制:



注意看

注意看

注意看,组件渲染次数1与源码中定死的1。



Alt text


好了,网友没说错。从结果上来看,数据确实就是写死的。这种行为如果用在官网和对比上,确实很难让人接受。


但是我还注意到一个问题,官网上并没有华为的大 logo,那么这东西到底是不是华为的?别啥脏水都往华为身上泼好吧!


然后我又再次陷入沉思:与华为什么关系?


inula 与华为的关系




9月21日,在华为全联接大会2023开源分论坛上,华为表示国内软件根技术创新之势已起,目前处于战略机遇期,有较大的市场空间。在这一契机下,华为发布了国内首个全面自研密码套件openHiTLS及全场景智慧前端框架openInula。这两款开源基础中间件助力软件根技术自主创新,对构筑业务数字化的核心竞争力有重要意义。



开发团队合影:


Alt text



华为ICT开源产业与生态发展部总经理周俊懿在发布会上表示,“国内软件根技术创新之势已起,正处于发展战略机遇期。在此我们发布更快、更简单、更智能的新一代智慧前端框架openInula,构筑前端生态创新底座,共建国内前端繁荣生态。”



Alt text



目前,华为公司内部已有多个产品采用openInula作为其前端框架运用于实际商用项目,支撑上千万行前端业务代码。openInula带来的性能提升解决了产品较多的前端性能瓶颈问题,保证了项目健康、高效地运行。



功能说明PPT:


Alt text


根据以上图文,我暂且觉得可以理解为 openInula 和华为有一定关系,是华为公司或旗下的团队。


简直不敢相信自己的眼睛!


Alt text


肯定是什么地方弄错了,openInula 就是有这么牛笔,人家发布会在那,官网在那,仓库在那,地面在那,还有假?BUG!肯定是BUG!


于是我开始从试图从代码上来实践看看,他确实 比传统虚拟DOM渲染效率更高、渲染次数更少


代码实践一


根据官网的步骤,我开始 npx create-inula <项目名>,这样就完全使用官方的脚手架模板生成项目,避免误差。



然后根据官方指示运行命令 npm run start



这怎么回事?我还没进入状态你就结束了?


Alt text


咦?不对,好像打开方式不对。


看到了 node:path 勾起了我仅有的记忆,大概是 node 版本过低。


这个我熟啊!于是我直接打开 package.json 文件,并加入以下代码:


"env": {
"node": "18.18.2"
}

然后再次运行命令,这时候项目就在 node v18 的环境下运行成功啦。




注意看

注意看

注意看,上图中在 package.json 中声明了当前项目依赖的环境。



当我打开控制台 url 时,页面并没有问题(没有使用官网声明的响应式API)。然后当我把官网上的响应式API示例代码放过来的时候:



啊啊啊!页面一片空白。


然后发现官网声明中的代码:


import { useRef, useReactive, useComputed } from 'openinula';

function ReactiveComponent() {
const renderCount = ++useRef(0).current;

const data = useReactive({ count: 0 });
const countText = useComputed(() => {
return `计时: ${data.count.get()}`;
});

setInterval(() => {
data.count.set(c => c + 1);
}, 1000);

return (
<div>
<div>{countText}</div>
<div>组件渲染次数:{renderCount}</div>
</div>

);
}

其中 openinula 的 useReactive, useComputed 两个 api 都是 undefined。


官网 api 文档中全局搜索 useReactive 也无任何讯息。


Alt text


好了,我累了,毁灭吧。因为它可能还在 ppt 里或者还没有发布。


然后我就开始相关如何结束本文,如何思考如何更中立一些,应放入哪些参考材料。


比如我准备去找代码仓库、去找 npm 官方以及其他镜像仓库来大概看看 openinula 的下载量时:



这是什么情况?



  • 版本 0.0.1,总历史版本 2个 -- 代表很新很新。难道是内网发布?

  • 周下载量 65 -- 代表 npm 上很少很少人用。 说好的支撑上千万行前端项目呢?难道是内网人用?

  • 代码仓库为空 -- ???

  • readem 为空 -- ???


有一说一,如果就上面这样子的话,真的是一点都不像官网所描述的光环拉满的样子。


真的,说实话到这里我的失望值已经累计到 0.2 了。


但是我真的还想再挣扎一下:难道是因为这是国产框架,所以大都使用国内镜像,所以 npm 上的下载量少?


然后去大家都在用的淘宝镜像上看了一下:



我再次被震惊了,请看图片左边是淘宝镜像,为 0.0.4 版,右边是 npm 惊喜,为 0.0.1 版本。大家都知道,通常都是 taobao 镜像比 npm 仓库更新要慢一些。但 openinula 在这里怎么就直接遥遥领先了 3 个版本?



虽然不理解,但大受震撼。不过,如果这是假的,我希望这是真的(因为我可以试试新的0.0.4版本)。如果这是真的,我希望这是假的,因为这太反常了。


image-5.png


所以,那就再试试淘宝镜像上的 0.0.4 是不是遥遥领先,吊打传统虚拟 dom hook api 吧。


代码实践二


这下我们直接:


nrm use taobao
npm i openinula@latest
npm run start


然后仍然是:



Alt text


页面一片空白,直接报错:jsx-dev-runtime 404 。



注意看

注意看

注意看:上图浏览器网络控制台。



真的,我累了,毁灭吧。


结论


确实在官网上,传统 Hook API 一栏中组件渲染次数是动态改变的,而在响应式 API声称响应式 API 避免重复运行组件,无需对比虚拟DOM树,可精准更新DOM,大幅提升网页性能。中组件渲染次数是写死的


但是,这么做的原因到底是什么,有有意为之,还是不小心写错了?就得继续等待后续了。


我斗胆猜测一下,有几个可能:


一:还没有准备好


虽然从发布会上和官网上来看,光环拉满,但从已发布的包、仓库、文档来看,还亟待完善……


二:失误


失误就是,真的那个地方是要再加个 t,让其出现动态渲染的效果。不过我尝试修复了这个问题,把 t 加上,也发现只是简单的定时器,所以应该不是少加个 t 的事情。




注意看

注意看

注意看上面的动图中,响应式 API 真的动起来了!



那种有没有可能是,这个地方是真的想做个动态渲染效果比较,但还没做出来?


另外,根据官方代码仓库中的源码(是活跃分支,最近提交2小时前)看来:


readme 中的开发工具都还是随手写的错误网址:


Alt text


package.json 声明当前版本为 0.0.1 (那 taobao 上的 0.0.4 是怎么回事)。
Alt text


三:有意为之


在不少关于 Inula 的文章,以及发表大会中,都是以响应式、区别传统虚拟DOM、提高效率号称的。并且代码也是开源的,如果是如实开源的情况下,到底效果是不是如官网所说,大佬们一挖代码便知。虽然目前官网也没有提供详细评测仓库,或三方评测结果。


可能真是有那么强的,并且也是有意在官网中用渲染次数1来体现效果以达到视觉要求,但没想到有些程序员非要扒代码看说这个地方一定要写个实例动态渲染出来。


参考



作者:程序媛李李李李李蕾
来源:juejin.cn/post/7307125383786119209
收起阅读 »

现代化 Android 开发:基础架构

Android 开发经过 10 多年的发展,技术在不断更迭,软件复杂度也在不断提升。到目前为止,虽然核心需求越来越少,但是对开发速度的要求越来越高。高可用、流畅的 UI、完善的监控体系等都是现在的必备要求了。国内卷的方向又还包括了跨平台、动态化、模块化。 目前...
继续阅读 »

Android 开发经过 10 多年的发展,技术在不断更迭,软件复杂度也在不断提升。到目前为止,虽然核心需求越来越少,但是对开发速度的要求越来越高。高可用、流畅的 UI、完善的监控体系等都是现在的必备要求了。国内卷的方向又还包括了跨平台、动态化、模块化。


目前的整体感觉就是,移动开发基本是奄奄一息了。不过也不用过于悲观:一是依旧有很多存量的 App 堪称屎山,是需要有维护人员的,就跟现在很多人去卷 framework 层一样,千万行代码中找 bug。 二是 AI 日益成熟,那么应用层的创新也会出现,在没有更简洁的设备出现前,手机还是主要载体,总归是需要移动开发去接入的,如果硬件层越来越好,模型直接跑在手机上也不是不可能,所以对跨平台技术也会是新一层的考验,有可能直接去跨平台化了。毕竟去中台化也成了历史的选择。


因而,在这个存量市场,虽然竞争压力很大,但是如果技术过硬,还是能寻求一席之地的。因而我决定用几篇文章来介绍下,当前我认为的现代化 Android 开发是怎样的。其目录为:



  • 现代化 Android 开发:基础架构(本文)

  • 现代化 Android 开发:数据类

  • 现代化 Android 开发:逻辑层

  • 现代化 Android 开发:组件化与模块化的抉择

  • 现代化 Android 开发:多 Activity 多 Page 的 UI 架构

  • 现代化 Android 开发:Jetpack Compose 最佳实践

  • 现代化 Android 开发:性能监控


Scope


提到 Android 基础架构,大家可能首先想到的是 MVCMVPMVVMMVI 等分层架构。但针对现代化的 Android 开发,我们首要有的是 scope 的概念。其可以分两个方面:



  • 结构化并发之 CoroutineScope:目前协程基本已经是最推荐的并发工具了,CoroutineScope 的就是对并发任务的管理,例如 viewModelScope 启动的任务的生命周期就小于 viewModel 的存活周期。

  • 依赖注入之 KoinScope:虽然官方推荐的是 hilt,但其实它并没有 koin 好用与简洁,所以我还是推荐 koinKoinScope 是对实例对象的管理,如果 scope 结束, 那么 scope 管理的所有实例都被销毁。


一般应用总会有登录,所以大体的 scope 管理流程图是这样的:


scope



  • 我们启动 app, 创建 AppScope,对于 koin 而言就是用于存放单例,对于协程来说就是全局任务

  • 当我们登录后,创建 AuthSessionScope, 对于 koin 而言,就是存放用户相关的单例,对于协程而言就是用户执行相关的任务。当退出登录时,销毁当前的 AuthSessionScope,那么其对应的对象实例、任务全部都会被销毁。用户再次登录,就再次重新创建 AuthSessionScope。目前很多 App 对于用户域内的实例,基本上还是用单例来实现,退出登录时,没得办法,就只能杀死整个进程再重启, 所以会有黑屏现象,实现不算优雅。而用 scope 管理后,就是一件很自然而实现的事情了。所以尽量用依赖注入,而不要用单例模式

  • 当我们进入界面后,一般都是从逻辑层获取数据进行渲染,所以依赖注入没多大用了。而协程的 lifecycleScopeviewModelScope 就比较有用,管理界面相关的异步任务。


所以我们在做架构、做某些业务时,首要考虑 scope 的问题。我们可以把 CoroutineScope 也作为实例存放到 KoinScope 里,也可以把 KoinScope 作为 Context 存放到 CorutineScope 里。


岐黄小筑是将 CoroutineScope 放到 koin 里去以便依赖查找


val sessionCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + coroutineLogExceptionHandler(TAG))
val sessionKoinScope = GlobalContext.get().createScope(...)
sessionKoinScope.declare(sessionCoroutineScope)


其实我们也完全可以用 CoroutineScopeContext 来做实例管理,而移除 koin 的使用。但是 Context 的使用并没有那么便捷,或许以后它可以进化为完全取代 koin



架构分层


随着软件复杂度的提升,MVCMVPMVVMMVI 等先后被提出,但我觉得目前所有的开发,都大体遵循某一模式而又不完全遵循,很容易因为业务的节奏,很容易打破,变成怎么方便怎么来。所以使用简单的分层 + 足够优秀的组件化,才是保证开发模式不被打破的最佳实践。下图是岐黄小筑的整体架构图:



整体架构不算复杂,其实重点是在于组件库,emo 已经有 20 个子库了,然后岐黄小筑有一些对于通用逻辑的抽象与封装,使得逻辑层虽然都集中在 logic 层,但整体都是写模板式的代码,可以面向 copy-paste 编程。


BookLogic 为例:



// 通过依赖注入传参, 拿到 db 层、网络层、以及用户态信息的应用
class BookLogic(
val authSession: AuthSession,
val kv: EmoKV,
val db: AccountDataBase,
private val bookApi: BookApi
) {
// 并发请求复用管理
private val concurrencyShare = ConcurrencyShare(successResultKeepTime = 10 * 1000L)

// 加载书籍信息,使用封装好的通用请求组件
fun logicBookInfo(bookId: Int, mode: Int = 0) = logic(
scope = authSession.coroutineScope, // 使用用户 session 协程 scope,因为有请求复用,所以退出界面,再进入,会复用之前的网络请求
mode = mode,
dbAction = { // 从 db 读取本地数据
db.bookDao().bookInfo(bookId)
},
syncAction = { // 从网络同步数据
concurrencyShare.joinPreviousOrRun("syncBookInfo-$bookId") {
bookApi.bookInfo(bookId).syncThen { _, data ->
db.runInTransaction {
db.userDao().insert(data.author)
db.bookDao().insert(data.info)
}
SyncRet.Full
}
}
}
)
// 类似的模板代码
suspend fun logicBookClassicContent(bookId: Int, mode: Int = 0) = logic(...)
suspend fun logicBookExpoundContent(bookId: Int, mode: Int = 0) = logic(...)
...
}

//将其注册到 `module` 中去,目前好像也可以通过注解的方式来做,不过我还没采用那种方式:
scopedOf(::BookLogic)

ViewModel 层浮层从 Logic 层读取数据,并可以进行特殊化处理:


class BookInfoViewModel(navBackStackEntry: NavBackStackEntry) : ViewModel() {
val bookId = navBackStackEntry.arguments?.getInt(SchemeConst.ARG_BOOK_ID) ?: throw RuntimeException("book_id is required!.")

val bookInfoFlow = MutableStateFlow(logicResultLoading<BookInfoPojo>())

init {
viewModelScope.launch {
runInBookLogic {
logicBookInfo(bookId, mode).collectLatest {
bookInfoFlow.emit(it)
}
}
}
}
}

Compose 界面再使用 ViewModel


@ComposeScheme(
action = SchemeConst.ACTION_BOOK_INFO,
alternativeHosts = [BookActivity::class]
)

@SchemeIntArg(name = SchemeConst.ARG_BOOK_ID)
@Composable
fun BookInfoPage(navBackStackEntry: NavBackStackEntry) {
LogicPage(navBackStackEntry = navBackStackEntry) {
val infoVm = schemeActivityViewModel<BookInfoViewModel>(navBackStackEntry)
val detailVm = schemeViewModel<BookDetailViewModel>(navBackStackEntry)
val bookInfo by infoVm.bookInfoFlow.collectAsStateWithLifecycle()
//...
}
}

这样整个数据流从网络加载、到存储到数据库、到传递给 UI 进行渲染的整个流程就结束了。


对于其中更多的细节,例如逻辑层具体是怎么封装的?UI 层具体是怎么使用多 ActivityPage?可以期待下之后的文章。


作者:古哥E下
来源:juejin.cn/post/7240636320762593338
收起阅读 »

Android - 你可能需要这样一个日志库

前言 目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少, 所以作者想自己造一个轮子。 这种api风格有什么不好呢? 首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满...
继续阅读 »

前言


目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少,
所以作者想自己造一个轮子。


这种api风格有什么不好呢?


首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满天飞。


另外,它也可能导致性能陷阱,假设有这么一段代码:


// 打印一个List
Log.d("tag", list.joinToString())

此处使用Debug打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()这一行代码仍然会被执行,有可能导致性能问题。


下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin,库中好用的api也是基于kotlin特性来实现的。


作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。


期望


什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式


inline fun <reified T : FLogger> flogD(block: () -> Any)

interface AppLogger : FLogger

flogD {
list.joinToString { it }
}

flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。


日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。


实现分析


日志库的完整实现已经写好了,放在这里xlog



  • 支持限制日志大小,例如限制每天只能写入10MB的日志

  • 支持自定义日志格式

  • 支持自定义日志存储,即如何持久化日志


这一节主要分析一下实现过程中遇到的问题。


问题:如果App运行期间日志文件被意外删除了,怎么处理?


在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,导致日志丢失,该如何解决?


有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。


检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:


measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58

可以看到1万次调用的耗时在50毫秒左右。


我们再测试一下对文件写入的耗时:


val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54

可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。


还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。


其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你注册一个IdleHandler后,它会在主线程空闲的时候被执行。


我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。


这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:


private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null

fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false

// 如果已经注册过了,直接返回
_idleHandler?.let { return true }

val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }

// 重置变量,等待下次注册
_idleHandler = null
false
}

// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}

这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。


问题:如何检测文件大小是否溢出


库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:


val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78

可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。


库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。


如果开发者自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里


问题:文件大小溢出后怎么处理?


假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。


例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。


有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:



  1. 写入文件20231128.log

  2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1


这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。


分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。


问题:打印日志的性能


性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里


还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:


val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193

可以看到1万次格式化耗时大概在200毫秒左右。


我们再用java.util.Calendar测试一下:


val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18


这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。


可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。


问题:日志的格式如何显示


手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。



  • 优化时间显示


目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。



  • 优化日志等级显示


打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:


private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}


  • 优化日志标识显示


如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag



  • 优化线程ID显示


如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID


经过上面的优化之后,日志打印的格式是这样的:


flogI { "1" }
flogI { "2" }
flogW { "3" }
flogI { "user debug" }
thread {
flogI { "thread" }
}

19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread

API


这一节介绍一下库的API


常用方法


/**
* 打开日志,文件保存目录:[Context.getFilesDir()]/flog,
* 默认只打开文件日志,可以调用[FLog.enableConsoleLog()]方法开关控制台日志,
*/

FLog.open(
context = this,

//(必传参数)日志等级 All, Verbose, Debug, Info, Warning, Error
level = FLogLevel.All,

//(可选参数)限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认100MB
limitMBPerDay = 100,

//(可选参数)自定义日志格式
formatter = AppLogFormatter(),

//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),
)


// 是否打打印控制台日志
FLog.enableConsoleLog(false)


/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/

FLog.deleteLog(1)


// 关闭日志
FLog.close()

打印日志


interface AppLogger : FLogger

flogV { "Verbose" }
flogD { "Debug" }
flogI { "Info" }
flogW { "Warning" }
flogE { "Error" }

// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }

配置日志标识


可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:


FLog.config {
// 修改日志等级
this.level = FLogLevel.Debug

// 修改tag
this.tag = "AppLoggerAppLogger"
}

自定义日志格式


class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}

interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>

/** 日志tag */
val tag: String

/** 日志内容 */
val msg: String

/** 日志等级 */
val level: FLogLevel

/** 日志生成的时间戳 */
val millis: Long

/** 日志是否在主线程生成 */
val isMainThread: Boolean

/** 日志生成的线程ID */
val threadID: String
}

自定义日志存储


日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。


class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}

class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}

// 返回当前日志的大小
override fun size(): Long = 0

// 关闭
override fun close() {}
}

结束


库目前还处于alpha阶段,如果有开发者遇到问题了可以及时反馈给作者,最后感谢大家的阅读。


作者:Sunday1990
来源:juejin.cn/post/7306423214493270050
收起阅读 »

Android集成Flutter模块经验记录

记录Android原生项目集成Flutter模块经验,其中不乏一些踩坑,也是几番查找资料之后才成功运用于实际开发。 主要为了记录,将使用简洁的描述。 Flutter开发环境 1. Flutter安装和环境配置 官方文档:flutter.cn/docs/get-...
继续阅读 »

记录Android原生项目集成Flutter模块经验,其中不乏一些踩坑,也是几番查找资料之后才成功运用于实际开发。

主要为了记录,将使用简洁的描述。


Flutter开发环境


1. Flutter安装和环境配置


官方文档:flutter.cn/docs/get-st…

参照官方文档一步步按步骤即可

下载SDK->解压->配置PATH环境变量

其中配置PATH环境变量务必使其永久生效方式


2. AS安装flutter和dart插件


AS安装flutter和dart插件


将 Flutter module 集成到 Android 项目


官方文档:flutter.cn/docs/add-to…

仍然主要是参照官方文档。

有分为使用AS集成和不使用AS集成,其中使用AS集成有AAR集成和使用模块源码集成两种方式。



  • AAR 集成: AAR 机制可以为每个 Flutter 模块创建 Android AAR 作为依赖媒介。当你的宿主应用程序开发者不想安装 Flutter SDK 时,这是一个很好方案。但是每次修改都需要重新编译。

  • 模块源码集成:直接将 Flutter 模块的源码作为子项目的依赖机制是一种便捷的一键式构建方案,但此时需要另外安装 Flutter SDK,这是目前 Android Studio IDE 插件使用的机制。


本文讲述的是使用模块源码集成的方式。


1.创建Flutter Module


使用File > New > New Flutter Project创建,选择Module,官方建议Flutter Module和Android项目在同一个目录下。
创建Flutter Module


2. 配置Module


在Android项目的 settings.gradle中添加以下配置:flutter_module为创建的flutter module名称


// Include the host app project.
include ':app' // assumed existing content
setBinding(new Binding([gradle: this])) // new
evaluate(new File( // new
settingsDir.parentFile, // new
'flutter_module/.android/include_flutter.groovy' // new
)) // new

在应用中引入对 Flutter 模块的依赖:


dependencies {
implementation project(':flutter')
}

3. 编译失败报错:Failed to apply plugin class 'FlutterPlugin'


gradle6.8后 在settings.gradle的dependencyResolutionManagement 下新增了如下配置:


repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)


RepositoriesMode配置在构建中仓库如何设置,总共有三种方式:

FAIL_ON_PROJECT_REPOS

表示如果工程单独设置了仓库,或工程的插件设置了仓库,构建就直接报错抛出异常

PREFER_PROJECT

表示如果工程单独设置了仓库,就优先使用工程配置的,忽略settings里面的

PREFER_SETTINGS

表述任何通过工程单独设置或插件设置的仓库,都会被忽略



settings.gradle里配置的是FAIL_ON_PROJECT_REPOS,Flutter插件又单独设置了repository,所以会构建报错,因此需要把FAIL_ON_PROJECT_REPOS改成PREFER_PROJECT。


因为gradle调整,Android仓库配置都在settings.gradle中,但是因为设置了PREFER_PROJECT,settings.gradle被忽略了,那该怎么解决呢?发现虽然project的gradle文件虽然调整了,但是依然可以跟之前一样配置仓库这些,于是在项目build.gradle中加上settings.gradle中的所有仓库,成功解决问题并编译安装成功。


allprojects{
repositories {
maven {
url "https://plugins.gradle.org/m2/"
}
maven { url "https://s01.oss.sonatype.org/content/groups/public" }
maven { url 'https://jitpack.io' }
google()
// 极光 fcm, 若不集成 FCM 通道,可直接跳过
maven { url "https://maven.google.com" }
maven {
url 'https://artifact.bytedance.com/repository/pangle'
}
}
}

总结:需要先将settings.gradle中repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)替换为repositoriesMode.set(RepositoriesMode.PREFER_PROJECT),
然后在项目build.gradle中添加settings.gradle中的所有仓库。


添加Flutter页面


官方文档:flutter.cn/docs/add-to…

Flutter 提供了 FlutterActivity,用于在 Android 应用内部展示一个 Flutter 的交互界面。需要在清单文件中注册FlutterActivity。


<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:screenOrientation="portrait"
/>

然后加载FlutterActivity


startActivity(
FlutterActivity.createDefaultIntent(requireContext())
)

此外还有withNewEngine、withCachedEngine等多种加载方式,具体可见官方文档。


添加Flutter视图


官方文档:flutter.cn/docs/add-to…

参考官方demo:github.com/flutter/sam…


创建FlutterViewEngine用以管理FlutterView、FlutterEngine、Activity三者。
FlutterEngine用以执行dart执行器,"showCell"为dart中方法名,FlutterEngine和FlutterView attach之后,会将"showCell"中生成的ui绘制到FlutterView上。


val engine = FlutterEngine(BaseApplication.instance)
engine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"showCell"))

在原生页面里面添加FlutterView还是比较麻烦的,需要开发者自己管理FlutterView、FlutterEngine、Activity三者之间生命周期联系。


作者:愿天深海
来源:juejin.cn/post/7306703076337483802
收起阅读 »

基于模块暴露和Hilt的Android模块通信方案

ModuleExpose 项目地址:github.com/JailedBird/… 序言 Android模块化必须要解决的问题是 如何实现模块间通信 ?而模块之间通信往往需要获取相同的实体类和接口,造成部分涉及模块通信的接口和实体类被迫下沉到基础模块,导致 基...
继续阅读 »

ModuleExpose


项目地址:github.com/JailedBird/…


序言


Android模块化必须要解决的问题是 如何实现模块间通信 ?而模块之间通信往往需要获取相同的实体类和接口,造成部分涉及模块通信的接口和实体类被迫下沉到基础模块,导致 基础模块代码膨胀、模块代码分散和不便维护等问题;


ModuleExpose方案使用模块暴露&依赖注入框架Hilt的方式,实现模块间通信:



  • 使用模块暴露(模块api化)解决基础模块下沉问题

  • 使用依赖注入框架Hilt实现基于接口的模块解耦方案


简介


ModuleExpose,是将module内部需要暴露的代码通过脚本自动暴露出来;不同于手动形式的接口下沉,ModuleExpose是直接将module中需要暴露的代码完整拷贝到module_expose模块,而module_expose模块的生成、拷贝和配置是由ModuleExpose脚本自动完成,并保证编译时两者代码的完全同步;


最终,工程中包含如下几类核心模块:




  • 基础模块:基础代码封装,可供任何业务模块使用;




  • 业务模块:包含业务功能,业务模块可以依赖基础模块,但无法依赖其他业务模块(避免循环依赖);




  • 暴露模块:由脚本基于业务模块或基础模块自动拷贝生成,业务模块可依赖其他暴露模块(通过compileOnly方式,只参与编译不参与打包),避免模块通信所需的接口、数据实体类下沉到基础模块,造成基础模块膨胀、业务模块核心类分散到基础模块等问题;




注意这种方案并非原创,原创出处如下:


思路原创:微信Android模块化架构重构实践



先寻找代码膨胀的原因。


翻开基础工程的代码,我们看到除了符合设计初衷的存储、网络等支持组件外,还有相当多的业务相关代码。这些代码是膨胀的来源。但代码怎么来的,非要放这?一切不合理皆有背后的逻辑。在之前的架构中,我们大量使用Event事件总线作为模块间通信的方式,也基本是唯一的方式。使用Event作为通信的媒介,自然要有定义它的地方,好让模块之间都能知道Event结构是怎样的。这时候基础工程好像就成了存放Event的唯一选择——Event定义被放在基础工程中;接着,遇到某个模块A想使用模块B的数据结构类,怎么办?把类下沉到基础工程;遇到模块A想用模块B的某个接口返回个数据,Event好像不太适合?那就把代码下沉到基础工程吧……


就这样越来越多的代码很“自然的”被下沉到基础工程中。


implementation工程提供逻辑的实现。api工程提供对外的接口和数据结构。library工程,则提供该模块的一些工具类。



项目原创: github/tyhjh/module_api



如果每次有一个模块要使用另一个模块的接口都把接口和相关文件放到公共模块里面,那么公共模块会越来越大,而且每个模块都依赖了公共模块,都依赖了一大堆可能不需要的东西;


所以我们可以提取出每个模块提供api的文件放到各种单独的模块里面;比如user模块,我们把公共模块里面的User和UserInfoService放到新的user-api模块里面,这样其他模块使用的时候可以单独依赖于这个专门提供接口的模块,以此解决公共模块膨胀的问题



本人工作:



  • 使用kts和nio重写脚本,基于性能的考量,对暴露规则和生成方式进行改进;

  • nowinandroid项目编译脚本系统、Ksp版本的Hilt依赖注入框架、示例工程三者结合起来,完善基于 模块暴露&依赖注入框架 的模块解耦示例工程;

  • 将api改名expose(PS:因内部项目使用过之前的api方案,为避免冲突所以改名,也避免和大佬项目名字冲突😘 脚本中亦可自定义关键词)


术语说明:



  • 部分博客中称这种方式为模块api化,我觉得这是合理的;本文的语境中的expose和api是等价的意思;


模块暴露


1、项目启用kts配置


因为脚本使用kts编写,因此需要在项目中启用kts配置;如因为gradle版本过低等原因导致无法接入kts,那应该是无法使用的;后续默认都开启kts,并使用kts语法脚本;


2、导入脚本到gradle目录&修改模板


请拷贝示例工程gradle/expose目录到个人项目gradle目录,拷贝后目录如下:


Path
ModuleExpose\gradle

gradle
│ libs.versions.toml
├─expose
│ build_gradle_template_android
│ build_gradle_template_java
│ build_gradle_template_expose
│ expose.gradle.kts
└─wrapper
gradle-wrapper.jar
gradle-wrapper.properties

其中:expose.gradle.kts是模块暴露的核心脚本,包含若干函数和配置参数;


其中:build_gradle_template_android和build_gradle_template_java脚本模板因项目不同而有所不同,需要自行根据项目修改,否则无法编译;




  • build_gradle_template_android,生成Android模块的脚本模板,注意高版本gradle必须配置namespace,因此最好保留如下的配置(细则见脚本如何处理的):


    android {
    namespace = "%s"
    }



  • build_gradle_template_java, 生成Java模块的脚本模板,配置较为简单;




  • includeWithApi函数使用build_gradle_template_android模板生成Android Library模块




  • includeWithJavaApi函数使用build_gradle_template_java模板生成Java Library模块




  • build_gradle_template_expose,不同于build_gradle_template_android、build_gradle_template_java的模板形式的配置,使用includeWithApi、includeWithJavaApi时,会优先检查模块根目录是否存在build_gradle_template_expose,如果存在则优先、直接将build_gradle_template_expose内容拷贝到module_expose, 作为build.gradle.kts ! 保留这个配置的原因在于:如果需要暴露的类,引用三方类如gson、但不便将三方库implementation到build_gradle_template_android,这会导致module_expose编译报错,因此为解决这样的问题,最好使用自定义module_expose脚本(拷贝module的配置、稍加修改即可)


    PS:注意这几个模板都是无后缀的,kts后缀文件会被IDE提示一大堆东西;




注意: Java模块编译更快,但是缺少Activity、Context等Android环境,请灵活使用;当然最灵活的方式是为每个module_expose单独配置build_gradle_template_expose (稍微麻烦一点);另外,如果不用includeWithJavaApi,其实build_gradle_template_java也是不需要的;


3、settings.gradle.kts导入脚本函数


根目录settings.gradle.kts配置如下:


apply(from = "$rootDir/gradle/expose/expose.gradle.kts")
val includeWithApi: (projectPaths: String) -> Unit by extra
val includeWithJavaApi: (projectPaths: String) -> Unit by extra

(PS:只要正确启用kts,settings.gradle应该也是可以导入includeWithApi的,但是我没尝试;其次老项目针对ModuleExpose改造kts时,可以渐进式改造,即只改settings.gradle.kts即可)


4、模块配置


将需要暴露的模块,在settings.gradle.kts 使用includeWithApi(或includeWithJavaApi)导入;


includeWithApi(":feature:settings")
includeWithApi(":feature:search")

即可自动生成新模块 ${module_expose};然后在模块源码目录下创建名为expose的目录,将需要暴露的文件放在expose目录下, expose目录下的文件即可在新模块中自动拷贝生成;


生成细则:


1、 模块支持多个expose目录(递归、含子目录)同时暴露,这可以避免将实体类,接口等全部放在单个expose,看着很乱


2、 expose内部的文件,默认全部复制,但脚本提供了开关,可以自行更改并配置基于文件名的拷贝过滤;


5、使用module_expose模块


请使用 compileOnly 导入项目,如下:


compileOnly(project(mapOf("path" to ":feature:search_expose")))

错误:会导致资源冲突


implementation(project(mapOf("path" to ":feature:search_expose")))

原理解释:compileOnly只参与编译,不会被打包;implementation参与编译和打包;


因此search_expose只能使用compileOnly导入,确保解耦的模块之间可以访问到类引用,但不会造成打包时2个类相同的冲突问题;


依赖注入


基于模块暴露的相关接口,可以使用依赖注入框架Hilt实现基于接口的解耦; 当然如果大家不使用Hilt技术栈的话,这节可以跳过;


本节内容会以业务模块search和settings为例,通过代码展示:



  • search模块跳转到settings模块,打开SettingsActivity

  • settings模块跳转到search模块,打开SearchActivity


PS:关于Hilt的配置和导入,本项目直接沿用nowinandroid工程中build-logic的配置,具体配置和使用请参考本项目和nowinandroid项目;


1、 基本配置&工程结构:


image.png


导入脚本之后,使用includeWithApi导入三个业务模块,各自生成对应的module_expose;


注意,请将*_expose/添加到gitignore,避免expose模块提交到git


2、 业务模块接口暴露&实现


settings模块expose目录下暴露SettingExpose接口, 脚本会自动将其同步拷贝到settings_expose中对应expose目录


image.png


exposeimpl/SettingExposeImpl实现SettingExpose接口的具体功能,完善跳转功能


class SettingExposeImpl @Inject constructor() : SettingExpose {
override fun startSettingActivity(context: Context) {
SettingsActivity.start(context)
}
}

3、 Hilt添加注入接口绑定


使用Hilt绑定全局单例SettingExpose接口实现,其对应实现为SettingExposeImpl


image.png


4、 search模块compileOnly导入settings_expose


compileOnly(projects.feature.settingsExpose)

注意,模块暴露依赖只能使用compileOnly,保证编译时候能找到对应文件即可;另外projects.feature.settingsExpose这种项目导入方式,需要在settings.gradle.kts启用project类型安全配置;


 enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

5、 search注入并使用SettingExpose


@AndroidEntryPoint
class SearchActivity : AppCompatActivity() {
@Inject
lateinit var settingExpose: SettingExpose

private val listener = object : AppSettingsPopWindow.Listener {

override fun settings() {
settingExpose.startSettingActivity(this@SearchActivity)
}
}
}

6、 实现解耦


最终实现【search模块跳转到settings模块,打开SettingsActivity】, 至于【settings模块跳转到search模块,打开SearchActivity】的操作完全一致,不重复叙述了;


参考资料


1、思路原创:微信Android模块化架构重构实践


2、项目原创:github/tyhjh/module_api


3、脚本迁移:将 build 配置从 Groovy 迁移到 KTS


4、参考文章:Android模块化设计方案之接口API化


5、Nowinandroid:github.com/android/now…


6、Dagger项目:github.com/google/dagg…


7、Hilt官方教程:developer.android.com/training/de…


作者:JailedBird
来源:juejin.cn/post/7305977644499419190
收起阅读 »

鸿蒙 akr ui 自定义弹窗实现教程

前言 各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章 效果图 具体实现: 1 弹窗部分布局 ...
继续阅读 »

前言


各位同学有段时间没有见面 因为一直很忙所以就没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章


效果图


image.png


具体实现:




  • 1 弹窗部分布局




image.png


@CustomDialog
struct CustomDialogExample {
@Link textValue: string
@Link inputValue: string
controller: CustomDialogController
// 若尝试在CustomDialog中传入多个其他的Controller,以
// 实现在CustomDialog中打开另一个或另一些CustomDialog,
// 那么此处需要将指向自己的controller放在最后
cancel: () => void
confirm: () => void

build() {
Column() {
Text('改变文本').fontSize(20).margin({ top: 10, bottom: 10 })
TextInput({ placeholder: '', text: this.textValue }).height(60).width('90%')
.onChange((value: string) => {
this.textValue = value
})
Text('是否更改文本?').fontSize(16).margin({top:20, bottom: 10 })
Flex({ justifyContent: FlexAlign.SpaceAround }) {
Button('取消')
.onClick(() => {
this.controller.close()
this.cancel()
}).backgroundColor(0xffffff).fontColor(Color.Black)
Button('确认')
.onClick(() => {
this.inputValue = this.textValue
this.controller.close()
this.confirm()
}).backgroundColor(0xffffff).fontColor(Color.Red)
}.margin({ top:20,bottom: 10 })
}.height('40%')
// dialog默认的borderRadius为24vp,如果需要使用border属性,请和borderRadius属性一起使用。
}
}

这边我们使用 Column 嵌套2个 text和一个 Flex 里面在嵌套2个 text来实现 :然后2个回调方法


控制器实现:


@Entry
@Component
struct CustomDialogUser {
@State textValue: string = ''
@State inputValue: string = '点击改变'
dialogController: CustomDialogController = new CustomDialogController({
builder: CustomDialogExample({
cancel: this.onCancel,
confirm: this.onAccept,
textValue: $textValue,
inputValue: $inputValue
}),
cancel: this.existApp,
autoCancel: true,
alignment: DialogAlignment.Center,
offset: { dx: 0, dy: -20 },
gridCount: 4,
customStyle: false
})

// 在自定义组件即将析构销毁时将dialogController置空
aboutToDisappear() {
this.dialogController = undefined // 将dialogController置空
}

onCancel() {
console.info('Callback when the first button is clicked')
}

onAccept() {
console.info('Callback when the second button is clicked')
}

existApp() {
console.info('点击退出app ')
}

build() {
Column() {
Button(this.inputValue)
.onClick(() => {
if (this.dialogController != undefined) {
this.dialogController.open()
}
}).backgroundColor(0x317aff)
}.width('100%').margin({ top: 50})
}
}

我们实现一个控制器容纳再 弹窗的构造方法里面设置 回调和我们的弹窗弹出位置:


dialogController: CustomDialogController = new CustomDialogController({
builder: CustomDialogExample({
cancel: this.onCancel,
confirm: this.onAccept,
textValue: $textValue,
inputValue: $inputValue
}),
cancel: this.existApp,
autoCancel: true,
alignment: DialogAlignment.Center,
offset: { dx: 0, dy: -20 },
gridCount: 4,
customStyle: false
})

在我们button点击后弹出


build() {
Column() {
Button(this.inputValue)
.onClick(() => {
if (this.dialogController != undefined) {
this.dialogController.open()
}
}).backgroundColor(0x317aff)
}.width('100%').margin({ top: 50})
}

在自定义组件即将析构销毁时将controller置空


// 在自定义组件即将析构销毁时将dialogController置空
aboutToDisappear() {
this.dialogController = undefined // 将dialogController置空
}

完整代码 :






// xxx.ets
@CustomDialog
struct CustomDialogExample {
@Link textValue: string
@Link inputValue: string
controller: CustomDialogController
// 若尝试在CustomDialog中传入多个其他的Controller,以
// 实现在CustomDialog中打开另一个或另一些CustomDialog,
// 那么此处需要将指向自己的controller放在最后
cancel: () => void
confirm: () => void

build() {
Column() {
Text('改变文本').fontSize(20).margin({ top: 10, bottom: 10 })
TextInput({ placeholder: '', text: this.textValue }).height(60).width('90%')
.onChange((value: string) => {
this.textValue = value
})
Text('是否更改文本?').fontSize(16).margin({top:20, bottom: 10 })
Flex({ justifyContent: FlexAlign.SpaceAround }) {
Button('取消')
.onClick(() => {
this.controller.close()
this.cancel()
}).backgroundColor(0xffffff).fontColor(Color.Black)
Button('确认')
.onClick(() => {
this.inputValue = this.textValue
this.controller.close()
this.confirm()
}).backgroundColor(0xffffff).fontColor(Color.Red)
}.margin({ top:20,bottom: 10 })
}.height('40%')
// dialog默认的borderRadius为24vp,如果需要使用border属性,请和borderRadius属性一起使用。
}
}






@Entry
@Component
struct CustomDialogUser {
@State textValue: string = ''
@State inputValue: string = '点击改变'
dialogController: CustomDialogController = new CustomDialogController({
builder: CustomDialogExample({
cancel: this.onCancel,
confirm: this.onAccept,
textValue: $textValue,
inputValue: $inputValue
}),
cancel: this.existApp,
autoCancel: true,
alignment: DialogAlignment.Center,
offset: { dx: 0, dy: -20 },
gridCount: 4,
customStyle: false
})

// 在自定义组件即将析构销毁时将dialogController置空
aboutToDisappear() {
this.dialogController = undefined // 将dialogController置空
}

onCancel() {
console.info('Callback when the first button is clicked')
}

onAccept() {
console.info('Callback when the second button is clicked')
}

existApp() {
console.info('点击退出app ')
}

build() {
Column() {
Button(this.inputValue)
.onClick(() => {
if (this.dialogController != undefined) {
this.dialogController.open()
}
}).backgroundColor(0x317aff)
}.width('100%').margin({ top: 50})
}
}

最后总结:


鸿蒙ark ui 里面的自定义弹窗和我们安卓还有flutter里面的差不多我们学会自定义弹窗理论上那些 警告弹窗 列表选择器弹窗, 日期滑动选择器弹窗 ,时间滑动选择器弹窗 ,文本滑动选择器弹窗 ,我们都是可以自己自定义实现的。这里就不展开讲有兴趣的同学可以自己多花时间研究实现一下,最后呢 希望我都文章能帮助到各位同学工作和学习 如果你觉得文章还不错麻烦给我三连 关注点赞和转发 谢谢


作者:坚果派_xq9527
来源:juejin.cn/post/7305983336496496650
收起阅读 »

Android Path路径旋转矩阵计算

一、前言 之前有一篇重点讲了三角形的绕环运动,主要重点内容是将不规则物体构造成一个正方形矩阵,便于计算中点,然后通过圆与切线的垂直关系计算出旋转角度。但实际上这种计算是利用了圆的特性,如果是不规则路径,物体该如何旋转呢 ? 实际上Android提供了一个非常强...
继续阅读 »

一、前言


之前有一篇重点讲了三角形的绕环运动,主要重点内容是将不规则物体构造成一个正方形矩阵,便于计算中点,然后通过圆与切线的垂直关系计算出旋转角度。但实际上这种计算是利用了圆的特性,如果是不规则路径,物体该如何旋转呢 ?


实际上Android提供了一个非常强大的工具——PathMeasure,可以通过片段计算出运动的向量,通过向量和x轴正方向的夹角的斜率就能计算出旋转角度 (这里就不推导了)。


二、效果预览



原理:


通过PathMeasure测量出position和正切的斜率,注意tan和position都是数组,[0]为x或者x方向,[1]为y或者为y方向,当然tan是带方向的矢量,计算公式是 A = ( x1-x2,y1-y2),这些是PathMeasure计算好的。


PathMeasure.getPosTan(mPathMeasure.getLength() * fraction, position, tan);

三、案例


下面是本篇自行车运行的轨迹


public class PathMoveView extends View {
private Bitmap mBikeBitmap;
// 圆路径
private Path mPath;
// 路径测量
private PathMeasure mPathMeasure;

// 当前移动值
private float fraction = 0;
private Matrix mBitmapMatrix;
private ValueAnimator animator;
// PathMeasure 测量过程中的坐标
private float[] position = new float[2];
// PathMeasure 测量过程中矢量方向与x轴夹角的的正切值
private float[] tan = new float[2];
private RectF rectHolder = new RectF();
private Paint mDrawerPaint;

public PathMoveView(Context context) {
super(context);
init(context);

}

public PathMoveView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);

}

public PathMoveView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}

protected void init(Context context) {
// 初始化 画笔 [抗锯齿、不填充、红色、线条2px]
mDrawerPaint = new Paint();
mDrawerPaint.setAntiAlias(true);
mDrawerPaint.setStyle(Paint.Style.STROKE);
mDrawerPaint.setColor(Color.WHITE);
mDrawerPaint.setStrokeWidth(2);

// 获取图片
mBikeBitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_bike, null);
// 初始化矩阵
mBitmapMatrix = new Matrix();

}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = 0;
if (heightMode == MeasureSpec.UNSPECIFIED) {
height = (int) dp2px(120);
} else if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(getMeasuredHeight(), getMeasuredWidth());
} else {
height = MeasureSpec.getSize(heightMeasureSpec);
}

setMeasuredDimension(getMeasuredWidth(), height);
}

@Override
protected void onDraw(Canvas canvas) {

int width = getWidth();
int height = getHeight();
if (width <= 1 || height <= 1) {
return;
}

if (mPath == null) {
mPath = new Path();
} else {
mPath.reset();
}
rectHolder.set(-100, -100, 100, 100);

mPath.moveTo(-getWidth() / 2F, 0);
mPath.lineTo(-(getWidth() / 2F + 200) / 2F, -400);
mPath.lineTo(-200, 0);
mPath.arcTo(rectHolder, 180, 180, false);
mPath.quadTo(300, -200, 400, 0);
mPath.lineTo(500, 0);

if (mPathMeasure == null) {
mPathMeasure = new PathMeasure();
mPathMeasure.setPath(mPath, false);
}

int saveCount = canvas.save();
// 移动坐标矩阵到View中间
canvas.translate(getWidth() / 2F, getHeight() / 2F);

// 获取 position(坐标) 和 tan(正切斜率),注意矢量方向与x轴的夹角
mPathMeasure.getPosTan(mPathMeasure.getLength() * fraction, position, tan);

// 计算角度(斜率),注意矢量方向与x轴的夹角
float degree = (float) Math.toDegrees(Math.atan2(tan[1], tan[0]));
int bmpWidth = mBikeBitmap.getWidth();
int bmpHeight = mBikeBitmap.getHeight();
// 重置为单位矩阵
mBitmapMatrix.reset();
// 旋转单位举证,中心点为图片中心
mBitmapMatrix.postRotate(degree, bmpWidth / 2, bmpHeight / 2);
// 将图片中心和移动位置对齐
mBitmapMatrix.postTranslate(position[0] - bmpWidth / 2,
position[1] - bmpHeight / 2);


// 画圆路径
canvas.drawPath(mPath, mDrawerPaint);
// 画自行车,使用矩阵旋转方向
canvas.drawBitmap(mBikeBitmap, mBitmapMatrix, mDrawerPaint);
canvas.restoreToCount(saveCount);
}

public void start() {

if (animator != null) {
animator.cancel();
}
ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1f);
valueAnimator.setDuration(6000);
// 匀速增长
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 第一种做法:通过自己控制,是箭头在原来的位置继续运行
fraction = (float) animation.getAnimatedValue();
postInvalidate();
}
});
valueAnimator.start();
this.animator = valueAnimator;
}

public void stop() {
if (animator == null) return;
animator.cancel();
}

public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

}

缺陷和问题处理:


从图上我们看到,车轮在路线的地下,这种视觉问题需要不断的修正和偏移才能得到解决,比如一段直线和圆面要分别计算偏移。


四、总结


PathMeasure 功能非常强大,可用于一般的在2D游戏中地图路线的计算,因此掌握好路径测量工具,可以方便我们做更多的东西。


作者:时光少年
来源:juejin.cn/post/7305235970286370827
收起阅读 »

Android自定义控件:一款多特效的智能loadingView

先上效果图(如果感兴趣请看后面讲解): 1、登录效果展示 2、关注效果展示 1、【画圆角矩形】 画图首先是onDraw方法(我会把圆代码写上,一步一步剖析): 首先在view中定义个属性:private RectF rectf = new RectF();...
继续阅读 »

先上效果图(如果感兴趣请看后面讲解):


1、登录效果展示


img


2、关注效果展示


img


1、【画圆角矩形】


画图首先是onDraw方法(我会把圆代码写上,一步一步剖析): 首先在view中定义个属性:private RectF rectf = new RectF();//可以理解为,装载控件按钮的区域


rectf.left = current_left;
rectf.top = 0; //(这2点确定空间区域左上角,current_left,是为了后面动画矩形变成等边矩形准备的,这里你可以看成0)
rectf.right = width - current_left;
rectf.bottom = height; //(通过改变current_left大小,更新绘制,就会实现了动画效果)
//画圆角矩形
//参数1:区域
//参数2,3:圆角矩形的圆角,其实就是矩形圆角的半径
//参数4:画笔
canvas.drawRoundRect(rectf, circleAngle, circleAngle, paint);

2、【确定控件的大小】


上面是画圆角,那width和height怎么来呢当然是通过onMeasure;


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
height = measuredHeight(heightMeasureSpec); //这里是测量控件大小
width = measureWidth(widthMeasureSpec); //我们经常可以看到我们设置控件wrap_content,match_content或者固定值
setMeasuredDimension(width, height);
}

下面以measureWidth为例:


private int measureWidth(int widthMeasureSpec) {
int result;
int specMode = MeasureSpec.getMode(widthMeasureSpec);
int specSize = MeasureSpec.getSize(widthMeasureSpec);
//这里是精准模式,比如match_content,或者是你控件里写明了控件大小
if (specMode == MeasureSpec.EXACTLY) {
result = specSize;
} else {
//这里是wrap_content模式,其实这里就是给一个默认值
//下面这段注销代码是最开始如果用户不设置大小,给他一个默认固定值。这里以字体长度来决定更合理
//result = (int) getContext().getResources().getDimension(R.dimen.dp_150);
//这里是我设置的长度,当然你写自定义控件可以设置你想要的逻辑,根据你的实际情况
result = buttonString.length() * textSize + height * 5 / 3;
if (specMode == MeasureSpec.AT_MOST) {
result = Math.min(result, specSize);
}
}
return result;
}

3、【绘制文字text】


这里我是用自己的方式实现:当文字长度超过控件长度时,文字需要来回滚动。所以自定义控件因为你需要什么样的功能可以自己去实现(当然这个方法也是在onDraw里,为什么这么个顺序讲,目的希望我希望你能循序渐进的理解,如果你觉得onDraw方代码太杂,你可以用个方法独立出去,你可以跟作者一样用private void drawText(Canvas canvas) {}), //绘制文字的路径(文字过长时,文字来回滚动需要用到)


private Path textPath = new Path():


textRect.left = 0;
textRect.top = 0;
textRect.right = width;
textRect.bottom = height; //这里确定文字绘制区域,其实就是控件区域
Paint.FontMetricsInt fontMetrics = textPaint.getFontMetricsInt();
//这里是获取文字绘制的y轴位置,可以理解上下居中
int baseline = (textRect.bottom + textRect.top - fontMetrics.bottom - fontMetrics.top) / 2;
//这里判断文字长度是否大于控件长度,当然我控件2边需要留文字的间距,所以不是大于width,这么说只是更好的理解
//这里是当文字内容大于控件长度,启动回滚效果。建议先看下面else里的正常情况
if ((buttonString.length() * textSize) > (width - height * 5 / 3)) {
textPath.reset();
//因为要留2遍间距,以heigh/3为间距
textPath.moveTo(height / 3, baseline);
textPath.lineTo(width - height / 3, baseline);
//这里的意思是文字从哪里开始写,可以是居中,这里是右边
textPaint.setTextAlign(Paint.Align.RIGHT);
//这里是以路径绘制文字,scrollSize可以理解为文字在x轴上的便宜量,同时,我的混动效果就是通过改变scrollSize
//刷新绘制来实现
canvas.drawTextOnPath(buttonString, textPath, scrollSize, 0, textPaint);
if (isShowLongText) {
//这里是绘制遮挡物,因为绘制路径没有间距这方法,所以绘制遮挡物类似于间距方式
canvas.drawRect(new Rect(width - height / 2 - textSize / 3, 0, width - height / 2, height),paintOval);
canvas.drawRect(new Rect(height / 2, 0, height / 2 + textSize / 3, height), paintOval);
//这里有个bug 有个小点-5 因画笔粗细产生
canvas.drawArc(new RectF(width - height, 0, width - 5, height), -90, 180, true, paintOval);
canvas.drawArc(new RectF(0, 0, height, height), 90, 180, true, paintOval);
}

if (animator_text_scroll == null) {
//这里是计算混到最右边和最左边的距离范围
animator_text_scroll = ValueAnimator.ofInt(buttonString.length() * textSize - width + height * 2 / 3,-textSize);
//这里是动画的时间,scrollSpeed可以理解为每个文字滚动控件外所需的时间,可以做成控件属性提供出去
animator_text_scroll.setDuration(buttonString.length() * scrollSpeed);
//设置动画的模式,这里是来回滚动
animator_text_scroll.setRepeatMode(ValueAnimator.REVERSE);
//设置插值器,让整个动画流畅
animator_text_scroll.setInterpolator(new LinearInterpolator());
//这里是滚动次数,-1无限滚动
animator_text_scroll.setRepeatCount(-1);
animator_text_scroll.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//改变文字路径x轴的偏移量
scrollSize = (int) animation.getAnimatedValue();
postInvalidate();
}
});
animator_text_scroll.start();
}
} else {
//这里是正常情况,isShowLongText,是我在启动控件动画的时候,是否启动 文字有渐变效果的标识,
//如果是长文字,启动渐变效果的话,如果控件变小,文字内容在当前控件外,会显得很难看,所以根据这个标识,关闭,这里你可以先忽略(同时因为根据路径绘制text不能有间距效果,这个标识还是判断是否在控件2遍绘制遮挡物,这是作者的解决方式,如果你有更好的方式可以在下方留言)
isShowLongText = false;
/**
* 简单的绘制文字,没有考虑文字长度超过控件长度
* */

//这里是居中显示
textPaint.setTextAlign(Paint.Align.CENTER);
//参数1:文字
//参数2,3:绘制文字的中心点
//参数4:画笔
canvas.drawText(buttonString, textRect.centerX(), baseline, textPaint);
}

4、【自定义控件属性】


"1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SmartLoadingView">
<attr name="textStr" format="string" />
<attr name="errorStr" format="string" />
<attr name="cannotclickBg" format="color" />
<attr name="errorBg" format="color" />
<attr name="normalBg" format="color" />
<attr name="cornerRaius" format="dimension" />
<attr name="textColor" format="color" />
<attr name="textSize" format="dimension" />
<attr name="scrollSpeed" format="integer" />
declare-styleable>

resources>

这里以,文案为例, textStr。比如你再布局种用到app:txtStr="文案内容"。在自定义控件里获取如下:


public SmartLoadingView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//自定义控件的3参方法的attrs就是我们设置自定义属性的关键
//比如我们再attrs.xml里自定义了我们的属性,
TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.SmartLoadingView);
//这里是获取用户有没有设置整个属性
//这里是从用户那里获取有没有设置文案
String title = typedArray.getString(R.styleable.SmartLoadingView_textStr);
if (TextUtils.isEmpty(title)){
//如果获取来的属性是空,那么可以默认一个属性
//(作者忘记设置了!因为已经发布后期优化,老尴尬了)
buttonString ="默认文案";
}else{
//如果有设置文案
buttonString = title;
}

}

5、【设置点击事件,启动动画】


为了点击事件的直观,也可以把处理防止重复点击事件封装在里面


//这是我自定义登录点击的接口
public interface LoginClickListener {
void click();
}

public void setLoginClickListener(final LoginClickListener loginClickListener) {
this.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (loginClickListener != null) {
//防止重复点击
if (!isAnimRuning) {
start();
loginClickListener.click();
}

}
}
});
}

6、【动画讲解】


6.1、第一个动画,矩形到正方形,以及矩形到圆角矩形(这里是2个动画,只是同时进行)


矩形到正方形(为了简化,我把源码一些其他属性去掉了,这样方便理解)


//其中  default_all_distance = (w - h) / 2;除以2是因为2遍都往中间缩短
private void set_rect_to_circle_animation() {
//这是一个属性动画,current_left 会在duration时间内,从0到default_all_distance匀速变化
//想添加多样化的话 还可以加入插值器。
animator_rect_to_square = ValueAnimator.ofInt(0, default_all_distance);
animator_rect_to_square.setDuration(duration);
animator_rect_to_square.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//这里的current_left跟onDraw相关,还记得吗
//onDraw里的控件区域
//控件左边区域 rectf.left = current_left;
//控件右边区域 rectf.right = width - current_left;
current_left = (int) animation.getAnimatedValue();
//刷新绘制
invalidate();
}
});

矩形到圆角矩形。就是从一个没有圆角的变成完全圆角的矩形,当然我展示的时候只有第三个图,最后一个按钮才明显了。


其他的我直接设置成了圆角按钮,因为我把圆角做成了一个属性。


还记得onDraw里的canvas.drawRoundRect(rectf, circleAngle, circleAngle, paint);circleAngle就是圆角的半径


可以想象一下如果全是圆角,那么circleAngle会是多少,当然是height/2;没错吧,所以


因为我把圆角做成了属性obtainCircleAngle是从xml文件获取的属性,如果不设置,则为0,就没有任何圆角效果


animator_rect_to_angle = ValueAnimator.ofInt(obtainCircleAngle, height / 2);
animator_rect_to_angle.setDuration(duration);
animator_rect_to_angle.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//这里试想下如果是一个正方形,刚好是圆形的圆角,那就是一个圆
circleAngle = (int) animation.getAnimatedValue();
//刷新绘画
invalidate();
}
});

2个属性动画做好后,用 private AnimatorSet animatorSet = new AnimatorSet();把属性动画加进去,可以设置2个动画同时进行,还是先后顺序 这里是同时进行所用用with


animatorSet
.play(animator_rect_to_square).with(animator_rect_to_angle);

6.2、变成圆形后,有一个loading加载动画


这里就是画圆弧,只是不断改变,圆弧的起始点和终点,最终呈现loading状态,也是在onDraw里


//绘制加载进度
if (isLoading) {
//参数1:绘制圆弧区域
//参数2,3:绘制圆弧起始点和终点
canvas.drawArc(new RectF(width / 2 - height / 2 + height / 4, height / 4, width / 2 + height / 2 - height / 4, height / 2 + height / 2 - height / 4), startAngle, progAngle, false, okPaint);

//这里是我通过实践,实现最佳loading动画
//当然这里有很多方式,因为我自定义这个view想把所有东西都放在这个类里面,你也可以有你的方式
//如果有更好的方式,欢迎留言,告知我一下
startAngle += 6;
if (progAngle >= 270) {
progAngle -= 2;
isAdd = false;
} else if (progAngle <= 45) {
progAngle += 6;
isAdd = true;
} else {
if (isAdd) {
progAngle += 6;
} else {
progAngle -= 2;
}
}
//刷新绘制,这里不用担心有那么多刷新绘制,会不会影响性能
//
postInvalidate();
}

6.3、loading状态,到打勾动画


那么这里首先要把loading动画取消,那么直接改变isLoading=false;不会只它同时启动打勾动画;打勾动画的动画,这里比较麻烦,也是我在别人自定义动画里学习的,通过PathMeasure,实现路径动画


/**
* 路径--用来获取对勾的路径
*/

private Path path = new Path();
/**
* 取路径的长度
*/

private PathMeasure pathMeasure;

//初始化打勾动画路径;
private void initOk() {
//对勾的路径
path.moveTo(default_all_distance + height / 8 * 3, height / 2);
path.lineTo(default_all_distance + height / 2, height / 5 * 3);
path.lineTo(default_all_distance + height / 3 * 2, height / 5 * 2);
pathMeasure = new PathMeasure(path, true);
}

//初始化打勾动画
private void set_draw_ok_animation() {
animator_draw_ok = ValueAnimator.ofFloat(1, 0);
animator_draw_ok.setDuration(duration);
animator_draw_ok.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public
void onAnimationUpdate(ValueAnimator animation) {
startDrawOk = true;
isLoading = false;
float value = (Float) animation.getAnimatedValue();
effect = new DashPathEffect(new float[]{pathMeasure.getLength(), pathMeasure.getLength()}, value * pathMeasure.getLength());
okPaint.setPathEffect(effect);
invalidate();

}
});
}

//启动打勾动画只需要调用
animator_draw_ok.start();

onDraw里绘制打勾动画


//绘制打勾,这是onDraw的,startDrawOk是判断是否开启打勾动画的标识
if (startDrawOk) {
canvas.drawPath(path, okPaint);
}

6.4、loading状态下回到失败样子(有点类似联网失败了)


之前6.1提到了矩形到圆角矩形和矩形到正方形的动画,


那么这里只是前面2个动画反过来,再加上联网失败的文案,和联网失败的背景图即刻


6.5、loading状态下启动扩散全屏动画(重点)


这里我通过loginSuccess里参数的类型启动不同效果:


1、启动扩散全屏动画
public void loginSuccess(Animator.AnimatorListener endListener) {}

2、启动打勾动画
public void loginSuccess(AnimationOKListener animationOKListener) {}

启动扩散全屏是本文的重点,里面还涉及到了一个自定义view


CirclBigView,这个控件是全屏的,而且是从一个小圆不断改变半径变成大圆的动画,那么有人会问,全屏肯定不好啊,会影响布局,
但是这里,我把它放在了activity的视图层:
ViewGr0up activityDecorView = (ViewGr0up) ((Activity) getContext()).getWindow().getDecorView();
ViewGr0up.LayoutParams layoutParams = new ViewGr0up.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, ViewGr0up.LayoutParams.MATCH_PARENT);
activityDecorView.addView(circlBigView, layoutParams);

这个灵感也是前不久在学习微信,拖拽退出的思路里发现的。全部代码如下:


public void toBigCircle(Animator.AnimatorListener endListener) {
//把缩小到圆的半径,告诉circlBigView
circlBigView.setRadius(this.getMeasuredHeight() / 2);
//把当前背景颜色告诉circlBigView
circlBigView.setColorBg(normal_color);
int[] location = new int[2];
//测量当前控件所在的屏幕坐标x,y
this.getLocationOnScreen(location);
//把当前坐标告诉circlBigView,同时circlBigView会计算当前点,到屏幕4个点的最大距离,即是当前控件要扩散到的半径
//具体建议读者看完本博客后,去下载玩耍下。
circlBigView.setXY(location[0] + this.getMeasuredWidth() / 2, location[1]);
if (circlBigView.getParent() == null) {
ViewGr0up activityDecorView = (ViewGr0up) ((Activity) getContext()).getWindow().getDecorView();
ViewGr0up.LayoutParams layoutParams = new ViewGr0up.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, ViewGr0up.LayoutParams.MATCH_PARENT);
activityDecorView.addView(circlBigView, layoutParams);
}
circlBigView.startShowAni(endListener);
isAnimRuning = false;
}

结束语


因为项目是把之前的功能写成了控件,所以有很多地方不完善。希望有建议的大牛和小伙伴,提示提示我,让我完善的更好。谢谢


作者:花海blog
来源:juejin.cn/post/7300845863462436873
收起阅读 »

Android 九宫格视频展示

一、前言 一个有趣的现象,抖音上一度热传九宫格视频,其本质都是利用视频合成算法将视频原有视频编辑裁剪,最终展示处理。但实际还有更简单的方法,无需编辑视频的情况下,同样也可以实现九宫格展示。 二、实现原理 2.1 原理 做音视频开发也有一段时间了,在这个领域很...
继续阅读 »

一、前言


一个有趣的现象,抖音上一度热传九宫格视频,其本质都是利用视频合成算法将视频原有视频编辑裁剪,最终展示处理。但实际还有更简单的方法,无需编辑视频的情况下,同样也可以实现九宫格展示。



二、实现原理


2.1 原理


做音视频开发也有一段时间了,在这个领域很多看似高大上的东西,实际上往往都有很多简便的方法去代替,从视频编辑到多屏投影,某些情况下并非一定要学习open gl才可以做到。


Android中提供了Path工具,其功能非常强大,很多不规则形状往往都需要Path实现,同样,本篇会利用Path进行镂空视频画布。


Path.Op 作为多个Path合成的重要操作符,其功能同样可以实现将Path闭合空间进行挖空的操作,目前主要有以下操作符。


Path.Op.DIFFERENCE          Path1调用合并函数:减去Path2后Path1区域剩下的部分
Path.Op.INTERSECT 保留Path2 和 Path1 共同的部分
Path.Op.UNION 保留Path1 和 Path 2
Path.Op.XOR 保留Path1 和 Path2 + 共同的部分
Path.Op.REVERSE_DIFFERENCE 与 Path.Op.DIFFERENCE相反,减去Path1后Path2区域剩下的部分


今天我们主要用到Path.Op.DIFFERENCE ,原因是XOR 多次存在叠加问题,下图Path节点的地方,实际上正如XOR所述进行了叠加,因此这里使用XOR效果不符合预期。



2.2 核心代码


        float columWidth = clipRect.width() / col;  //每列的宽度
float rowHeight = clipRect.height() / row; //每行的高度


for (int i = 1; i < col; i++) {
tmpPath.reset();
float position = i * columWidth - lineWidth/2;
tmpPath.addRect(offsetLeft + position, offsetTop, offsetLeft + position + lineWidth / 2, height - offsetBottom, Path.Direction.CCW);
clipPath.op(tmpPath, Path.Op.XOR);
}
for (int i = 1; i < row; i++) {
tmpPath.reset();
float position = i * rowHeight - lineWidth/2;
tmpPath.addRect(offsetLeft , offsetTop + position, width - offsetRight, offsetTop + position + lineWidth / 2, Path.Direction.CCW);
clipPath.op(tmpPath, Path.Op.XOR);
}

尝试修改行列的效果



三、完整代码


public class GridFrameLayout extends FrameLayout {
private Path clipPath;
private Path tmpPath = new Path();
private RectF clipRect;
private Paint paint;
//由于有的视频存在黑边,添加如下offset便于剔除黑边,保留纯画面区域
private int offsetTop = 0;
private int offsetBottom = 0;
private int offsetRight = 0;
private int offsetLeft = 0;
private PaintFlagsDrawFilter mPaintFlagsDrawFilter;

private int row = 3; //行数
private int col = 4; //列数

private int lineWidth = 0;


public GridFrameLayout(Context context) {
super(context);
init();
}

public GridFrameLayout(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public GridFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {
mPaintFlagsDrawFilter = new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG);
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(Color.WHITE);
paint.setStrokeWidth(10);
paint.setStyle(Paint.Style.STROKE);
lineWidth = dpToPx(5);
}

public void setRow(int row) {
this.row = row;
}
public void setColum(int col) {
this.col = col;
}
public void setOffsetTop(int offsetTop) {
this.offsetTop = offsetTop;
}
public void setOffsetBottom(int offsetBottom) {
this.offsetBottom = offsetBottom;
}

public void setOffsetRight(int offsetRight) {
this.offsetRight = offsetRight;
}

public void setOffsetLeft(int offsetLeft) {
this.offsetLeft = offsetLeft;
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
clipRect = null;
}

@Override
protected void dispatchDraw(Canvas canvas) {
int height = getHeight();
int width = getWidth();
DrawFilter drawFilter = canvas.getDrawFilter();

int saveCount = canvas.save();
//保存当前状态

canvas.setDrawFilter(mPaintFlagsDrawFilter);
if (clipPath == null) {
clipPath = new Path();
}
if (clipRect == null) {
clipRect = new RectF(offsetLeft, offsetTop, width - offsetRight, height - offsetBottom);
} else {
clipRect.set(offsetLeft, offsetTop, width - offsetRight, height - offsetBottom);
}

clipPath.reset();
float radius = dpToPx(10);
float[] radii = new float[]{
radius, radius,
radius, radius,
radius, radius,
radius, radius
};

clipPath.addRoundRect(clipRect, radii, Path.Direction.CCW);

float columWidth = clipRect.width() / col;
float rowHeight = clipRect.height() / row;

for (int i = 1; i < col; i++) {
tmpPath.reset();
float position = i * columWidth - lineWidth / 2;
tmpPath.addRect(offsetLeft + position, offsetTop, offsetLeft + position + lineWidth / 2, height - offsetBottom, Path.Direction.CCW);
clipPath.op(tmpPath, Path.Op.DIFFERENCE);
}
for (int i = 1; i < row; i++) {
tmpPath.reset();
float position = i * rowHeight - lineWidth / 2;
tmpPath.addRect(offsetLeft, offsetTop + position, width - offsetRight, offsetTop + position + lineWidth / 2, Path.Direction.CCW);
clipPath.op(tmpPath, Path.Op.DIFFERENCE);
}

canvas.clipPath(clipPath);
//裁剪画布,注意,这里不仅裁剪外围,内部挖空区域也会被裁剪
//为什么在dispatchDraw中使用,因为dispatchDraw方便控制子View的绘制
super.dispatchDraw(canvas);

canvas.restoreToCount(saveCount);
//恢复到之前的区域

canvas.setDrawFilter(drawFilter);
if (hasFocus()) {
canvas.drawPath(clipPath, paint); //有焦点时画一个边框
}
}
private int dpToPx(int dps) {
return Math.round(getResources().getDisplayMetrics().density * dps);
}
public void setLineWidth(int lineWidth) {
this.lineWidth = lineWidth;
}
}

四、总结


Canvas 作为2D绘制常用的组件,其实有很高级功能,如Matrix、Camera、Shader、drawBitmapMesh等,正确的使用往往能带来事半功倍的效果,因此有必要通过不断的摸索才能发挥极致。


作者:时光少年
来源:juejin.cn/post/7304272076641484834
收起阅读 »

2023小红书Android面试之旅

一面 自我介绍 看你写了很多文章,拿你理解最深刻的一篇出来讲一讲 讲了Binder相关内容 Binder大概分了几层 哪些方法调用会涉及到Binder通信 大概讲一下startActivity的流程,包括与AMS的交互 全页面停留时长埋...
继续阅读 »

一面




  • 自我介绍




  • 看你写了很多文章,拿你理解最深刻的一篇出来讲一讲


    讲了Binder相关内容




  • Binder大概分了几层




  • 哪些方法调用会涉及到Binder通信




  • 大概讲一下startActivity的流程,包括与AMS的交互




  • 全页面停留时长埋点是怎么做的


    我在项目中做过的内容,主要功能是计算用户在每个Activity的停留时长,并且支持多进程。这里的多进程支持主要是通过以ContentProvider作为中介,然后通过ContentResolver.call方法去调用它的各种方法以实现跨进程




  • 动态权限申请是什么


    详见 Android动态权限申请从未如此简单 这篇文章




  • 你做的性能监测工具,FPS是怎么采集的




  • 性能监测工具用在了什么场景




  • 有没有通过这个性能监测工具去做一些优化




  • 图片库,例如Glide,一般对Bitmap有哪些优化点




  • 过期的Bitmap可以复用吗




  • 有没有基于ASM插桩做过一些插件




  • 讲了一下当时做过的一个个人项目 FastInflate


    这个项目没能达到最终的目标,但通过做这个项目学习了很多新知识,比如APT代码生成、阅读了LayoutInflater源码、AppCompatDelegateImpl实现的LayoutInflater.Factory2会极大的拖慢布局创建的速度等




  • 怎么优化布局创建速度


    提示了预加载,但我当时脑抽在纠结xml的缓存,没想到可以提前把视图先创建好




  • 说一下你觉得你最擅长或者了解最透的点


    我回答的自定义View




  • 解决过View的滑动冲突吗




  • 讲解了一个之前写过的开源控件 SwipeLoadingLayout




  • 一般遇到困难的解决方案是什么




  • 算法题:反转链表




  • 反问阶段




    • 咱们组主要负责哪些内容




    • 主要使用Java还是Kotlin


      Kotlin




    • 小红书的面试一般是怎么个流程?多少轮?


      一般三轮技术面,一轮HR面




    • 面试完一般多久会给到结果


      比较快,一两天的样子






二面




  • 自我介绍




  • 为什么这个时间节点想要出来换工作呢




  • 在B站这些年做了什么




  • 做了哪些基础组件


    讲解了一下之前写的 SwipeLoadingLayout




  • 介绍一下Android的事件传递机制




  • 你写的这个分享模块是如何设计的


    对外采用流式调用的形式,内部通过策略模式区分不同的平台以及分享类型,给每个平台创建了一个中间Activity作为分享SDK请求的发起方(SDK.getApi().share())以及分享结果的接收方(onActivityResult),然后通过广播将分享的结果送入到分享模块内进行处理,最终调用用户设置的分享回调告知结果




  • 看你之前在扇贝的时候有开发过一些性能监测工具,那有做过性能优化吗




  • 你是如何收集这些性能数据的




  • 有没有对哪方面做过一些针对性的优化




  • Android系统为什么会触发ANR,它的机制是什么




  • 有解过ANR相关的问题吗?有哪几种类型?




  • 算法题:二叉树的层序遍历




  • Queue除了LinkedList还有哪些实现类




  • 现在还在面其他公司吗?你自己后面职业生涯的选择是怎么样的?




  • 给我介绍了一下团队,说我面试的这个部门应该说是小红书最核心的团队,包括主页、搜索、图文、视频等等都在部门业务范畴内,部门主要分三层,除了业务层之外还有基础架构层以及性能优化层




  • 反问阶段




    • 部门分三层的话,那新人进来的话是需要从业务层做起吗?


      不是这样的,我们首先会考虑这个同学能干什么,然后会考虑这个同学愿意去做什么,进来后,有经验的同学也会来带你的,不会一上来就让你抗输出,总之会把人放到适合他的团队里




    • 小红书会使用到一些跨端技术吗?


      会,之前在一些新的App上使用的Flutter,现在主要用的是RN,还会使用到一些DSL,这个不能算跨段。为什么在小红书社区App中跨端技术提及的比较少,是因为小红书App非常重视用户体验,对性能的要求比较高






三面




  • 自我介绍




  • 介绍一下目前负责的业务




  • 工作过程中有碰到过什么难题,最后是怎么解决的


    一开始脑抽了没想到该说什么,随便扯了一个没啥技术含量的东西,又扯了一个之前做的信号捕获的工具,后来回忆起来了,重新说了一个关于DEX编排的东西(主DEX中方法数超过65535导致打包失败,写了个脚本将一部分Class从主DEX中移除到其他DEX中)




  • 如何设计一个头像的自定义View,要求使头像展示出来是一个圆形




  • 介绍一下Android事件的分发流程




  • 如何处理View的防误触




  • 怎么处理滑动冲突




  • ActivityonCreate方法中调用了finish方法,那它的生命周期会是怎样的




  • 如果我想判断一个Activity中的一个View的尺寸,那我什么时候能够拿到




  • RecyclerView如何实现一个吸顶效果




  • JavaKoltin你哪个用的比较多




  • 有用过Kotlin的协程吗




  • Kotlin中的哪些Feature你用的多,觉得写的好呢




  • 你是怎么理解MVVM




  • 你有用过Jetpack Compose




  • 有用过kotlin中的by lazylateinit




  • kotlin中怎么实现单例,怎么定义一个类的静态变量




  • 算法题:增量元素之间的最大差值




  • 你这次看机会的原因是什么




  • 反问阶段我感觉之前问的差不多了,这次就没再问什么问题了




HR面




  • 现在是离职还是在职状态




  • 介绍一下之前负责的工作




  • 用户量怎么样




  • 这个项目是从0到1开发的吗




  • 这个业务有什么特点,对于客户端开发有什么挑战与困难




  • 团队分工是怎样的




  • 这个项目能做成现在这个样子,你自己的核心贡献有哪些




  • 这个事情对你来说有什么收获吗




  • 在B站的工作节奏是怎么样的




  • 离职的原因是什么呢




  • 你自己希望找一个什么样的环境或者什么阶段的业务




  • 你对小红书有什么了解吗




  • 未来两三年对于职业发展的想法




  • 你觉得现在有什么限制了你或者你觉得你需要提升哪些部分




  • 反问阶段



    • 问了一些作息、福利待遇之类的问题




总结


小红书面试总体而言给我的体验是很好的,每轮面试后基本上都是当天就能出结果,然后约下一轮的面试。最终从一面到HR面结束出结果,一共花了9天时间,还是挺快的。二面结束后,一面的面试官加我微信说小红书目前很缺人,感兴趣的同学也可以来试试。


作者:dreamgyf
来源:juejin.cn/post/7304267413637333029
收起阅读 »

关于鸿蒙开发,我暂时放弃了

起因 在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。 # 鸿蒙HarmonyOS从零实现类微信app效果第一篇,基础界面搭建 # 鸿蒙HarmonyOS从零实现类微信app效果第二篇,我的+发现...
继续阅读 »

image.png


image.png


起因


在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。


企业微信截图_6f8acb94-bd68-4f56-9460-4a59d2370a4a.png



鸿蒙的arkui,使用typescript作为基调,然后响应式开发,对于我这个old android来说,确实挺惊艳的。而且在模拟器中运行起来也很快,写demo的过程鸡血满满,着实很愉快。


后面自己写的文章,也在掘金站点上获得了不错的评价。


企业微信截图_fa34f233-af43-4567-8dac-57ef5666f1bd.png


image.png


打击


今天下午,刚好同事有一个遥遥领先(meta 40 pro),鸿蒙4.0版本


怀着秀操作的想法,在同事手机上运行了起来。very nice。 一切出奇的顺利。


but ...


尼玛,点击的时候,直接卡住不对,黑屏。让人瞬间崩溃。


本着优先怀疑自己的原则,我找了一个官方的demo。 运行起来。


额...


尼玛。还是点击之后卡住了,大概30s之后,才跳转到新的页面。


image.png


这一切,让我熬夜掉的头发瞬间崩溃。


放弃了...


放弃了...


后续


和其他学习鸿蒙的伙伴沟通,也遇到了同样的问题,真机不能运行,会卡线程。但是按下home键,再次回到界面,页面会刷新过来


我个人暂时决定搁置对于鸿蒙开发的学习了,后续如果慢慢变得比较成熟之后,再次接触学习吧。


作者:王先生技术栈
来源:juejin.cn/post/7304538094736343052
收起阅读 »

Android 自定义理化表达式View

一、前言 在 Android 中实现上下标我们一般使用 SpannableString 去完成,需要计算开始位置和结束位置,也要设置各种 Span,而且动态性不是很好,因为无法做到规则统一约束,因此有必要进行专有规则设定,提高代码使用的灵活程度。 当然,也有很...
继续阅读 »

一、前言


在 Android 中实现上下标我们一般使用 SpannableString 去完成,需要计算开始位置和结束位置,也要设置各种 Span,而且动态性不是很好,因为无法做到规则统一约束,因此有必要进行专有规则设定,提高代码使用的灵活程度。


当然,也有很多开源的项目,但是对于简单的数学和化学表达式,大多都缺少通用性,仅限于项目本身使用,这也是本篇实现的主要目的之一。对于其他类型如求和公式、平方根公式、分子分母其实也可以通过本篇的思想,进行一些列改造即可,当然也可以借助语法树,实现自己的公式编辑器。



二、效果预览



三、实现


实现其实很简单,本身就是借助Canvas#drawTextXXX实现,但是我们这里仍然需要回顾的问题是字体测量和基线计算问题。


3.1 字体测量


常用的宽高测量如下


        //获取文本最小宽度(真实宽度)
private static int getTextRealWidth(String text, Paint paint) {
if (TextUtils.isEmpty(text)) return 0;
Rect rect = new Rect(); // 文字所在区域的矩形
paint.getTextBounds(text, 0, text.length(), rect);
//获取最小矩形,该矩形紧贴文字笔画开始的位置
return rect.width();
}

//获取文本最小高度(真实高度)
private static int getTextRealHeight(String text, Paint paint) {
if (TextUtils.isEmpty(text)) return 0;
Rect rect = new Rect(); // 文字所在区域的矩形
paint.getTextBounds(text, 0, text.length(), rect);
//获取最小矩形,该矩形紧贴文字笔画开始的位置
return rect.height();
}

//真实宽度 + 笔画左右两侧间隙(一般绘制的的时候建议使用这种,左右两侧的间隙和字形有关)
private static int getTextWidth(String text, Paint paint) {
if (TextUtils.isEmpty(text)) return 0;
return (int) paint.measureText(text);
}

//真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
private static int getTextHeight(Paint paint) {
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
return textHeight;
}

3.2基线计算


在Canvas 绘制,实际上Html中的Canvas一样都需要计算意义,因为文字的受到不同文化的影响,表现形式不同,另外音标等问题存在,所以使用基线来绘制更合理。



推导算法如下


       /**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/

public static float getTextPaintBaseline(Paint p) {
Paint.FontMetrics fontMetrics = p.getFontMetrics();
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

3.3 全部代码


public class MathExpressTextView extends View {

private final List<TextInfo> TEXT_INFOS = new ArrayList<>();
private int textSpace = 15;
private String TAG = "MathExpressTextView";
protected Paint mTextPaint;
protected Paint mSubTextPaint;
protected Paint mMarkTextPaint;
protected float mContentWidth = 0f;
protected float mContentHeight = 0f;
protected float mMaxSize = 0;


public void setMaxTextSize(float sizePx) {
mMaxSize = sizePx;

mTextPaint.setTextSize(mMaxSize);
mSubTextPaint.setTextSize(mMaxSize / 3f);
mMarkTextPaint.setTextSize(mMaxSize / 2f);

invalidate();
}

public void setTextSpace(int textSpace) {
this.textSpace = textSpace;
}

public void setContentHeight(float height) {
mContentHeight = height;
invalidate();
}

public MathExpressTextView(Context context){
this(context,null);
}
public MathExpressTextView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
setEditDesignTextInfos();
}

public MathExpressTextView setText(String text, String subText, String supText, float space) {
this.TEXT_INFOS.clear();
TextInfo.Builder tb = new TextInfo.Builder(text, mTextPaint, mSubTextPaint)
.subText(subText)
.supText(supText)
.textSpace(space);

this.TEXT_INFOS.add(tb.build());
return this;
}

public MathExpressTextView appendMarkText(String text) {
TextInfo.Builder tb = new TextInfo.Builder(text, mMarkTextPaint, mMarkTextPaint);
this.TEXT_INFOS.add(tb.build());
return this;
}

public MathExpressTextView appendText(String text, String subText, String supText, float space) {
TextInfo.Builder tb = new TextInfo.Builder(text, mTextPaint, mSubTextPaint)
.subText(subText)
.supText(supText)
.textSpace(space);

this.TEXT_INFOS.add(tb.build());
return this;
}

private void setEditDesignTextInfos() {

if (!isInEditMode()) return;
// setText("2H", "2", "", 10)
// .appendMarkText("+");
// appendText("O", "2", "", 10);
// appendMarkText("=");
// appendText("2H", "2", "", 10);
// appendText("O", "", "", 10);

// setText("sin(Θ+α)", "", "", 10)
// .appendMarkText("=");
// appendText("sinΘcosα", "", "", 10);
// appendMarkText("+");
// appendText("cosΘsinα", "", "", 10);

setText("cos2Θ", "1", "", 10)
.appendMarkText("=");
appendText("cos", "", "2", 10);
appendText("Θ", "1", "", 10);
appendMarkText("-");
appendText("sin", "", "2", 10);
appendText("Θ", "1", "", 10);

}
public Paint getTextPaint() {
return mTextPaint;
}

public Paint getSubTextPaint() {
return mSubTextPaint;
}

public Paint getMarkTextPaint() {
return mMarkTextPaint;
}

private float dpTopx(int dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}


private void init() {

mTextPaint = new Paint();
mTextPaint.setColor(Color.WHITE);
mTextPaint.setAntiAlias(true);
mTextPaint.setStyle(Paint.Style.STROKE);

mMarkTextPaint = new Paint();
mMarkTextPaint.setColor(Color.WHITE);
mMarkTextPaint.setAntiAlias(true);
mMarkTextPaint.setStyle(Paint.Style.STROKE);

mSubTextPaint = new Paint();
mSubTextPaint.setColor(Color.WHITE);
mSubTextPaint.setAntiAlias(true);
mSubTextPaint.setStyle(Paint.Style.STROKE);

setMaxTextSize(dpTopx(30));

}


private void setSubTextShader() {
if (this.colors != null) {
float textHeight = mSubTextPaint.descent() - mSubTextPaint.ascent();
float textOffset = (textHeight / 2) - mSubTextPaint.descent();
Rect bounds = new Rect();
mSubTextPaint.getTextBounds("%", 0, 1, bounds);
mSubTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f, 0,
mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
} else {
mSubTextPaint.setShader(null);
}

}

private void setMarTextShader() {
if (this.colors != null) {
float textHeight = mMarkTextPaint.descent() - mMarkTextPaint.ascent();
float textOffset = (textHeight / 2) - mMarkTextPaint.descent();
Rect bounds = new Rect();
mMarkTextPaint.getTextBounds("%", 0, 1, bounds);
mMarkTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f, 0,
mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
} else {
mMarkTextPaint.setShader(null);
}

}

private void setTextShader() {
if (this.colors != null) {
float textHeight = mTextPaint.descent() - mTextPaint.ascent();
float textOffset = (textHeight / 2) - mTextPaint.descent();
Rect bounds = new Rect();
mTextPaint.getTextBounds("A", 0, 1, bounds);
mTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset, 0, mContentHeight / 2 + textOffset - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
} else {
mTextPaint.setShader(null);
}
}


public void setColor(int unitColor, int numColor) {
mSubTextPaint.setColor(unitColor);
mTextPaint.setColor(numColor);
}

RectF contentRect = new RectF();

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

if (mContentWidth <= 0) {
mContentWidth = getWidth();
}
if (mContentHeight <= 0) {
mContentHeight = getHeight();
}

if (mContentWidth == 0 || mContentHeight == 0) return;

setTextShader();
setSubTextShader();
setMarTextShader();

if (TEXT_INFOS.size() == 0) return;

int width = getWidth();
int height = getHeight();


contentRect.left = (width - mContentWidth) / 2f;
contentRect.top = (height - mContentHeight) / 2f;
contentRect.right = contentRect.left + mContentWidth;
contentRect.bottom = contentRect.top + mContentHeight;

int id = canvas.save();
float centerX = contentRect.centerX();
float centerY = contentRect.centerY();
canvas.translate(centerX, centerY);

contentRect.left = -centerX;
contentRect.right = centerX;
contentRect.top = -centerY;
contentRect.bottom = centerY;


float totalTextWidth = 0l;
int textCount = TEXT_INFOS.size();

for (int i = 0; i < textCount; i++) {
totalTextWidth += TEXT_INFOS.get(i).getTextWidth();
if (i < textCount - 1) {
totalTextWidth += textSpace;
}
}

drawGuideBaseline(canvas, contentRect, totalTextWidth);

float startOffsetX = -(totalTextWidth) / 2f;
for (int i = 0; i < textCount; i++) {
TEXT_INFOS.get(i).draw(canvas, startOffsetX, contentRect.centerY());
startOffsetX += TEXT_INFOS.get(i).getTextWidth() + textSpace;
}

canvas.restoreToCount(id);

}

private void drawGuideBaseline(Canvas canvas, RectF contentRect, float totalTextWidth) {

if (!isInEditMode()) return;

Paint guidelinePaint = new Paint();
guidelinePaint.setAntiAlias(true);
guidelinePaint.setStrokeWidth(0);
guidelinePaint.setStyle(Paint.Style.FILL);

RectF hline = new RectF();
hline.top = -1;
hline.bottom = 1;
hline.left = -totalTextWidth / 2;
hline.right = totalTextWidth / 2;
canvas.drawRect(hline, guidelinePaint);

RectF vline = new RectF();
hline.left = -1;
vline.top = contentRect.top;
vline.bottom = contentRect.bottom;
vline.right = 1;

canvas.drawRect(vline, guidelinePaint);
}


private static class TextInfo {
Paint subOrSupTextPaint = null;
String subText = null;
String supText = null;
Paint textPaint = null;
String text;
float space;

private TextInfo(String text, String subText, String supText, Paint textPaint, Paint subOrSupTextPaint, float space) {
this.text = text;
if (this.text == null) {
this.text = "";
}
this.subText = subText;
this.supText = supText;
this.space = space;
this.textPaint = textPaint;
this.subOrSupTextPaint = subOrSupTextPaint;
}

public void draw(Canvas canvas, float startX, float startY) {

if (this.textPaint == null) {
return;
}

canvas.drawText(this.text, startX, startY + getTextPaintBaseline(this.textPaint), this.textPaint);

if (this.subOrSupTextPaint == null) {
return;
}
if (this.supText != null) {
RectF rect = new RectF();
rect.left = startX + space + getTextWidth(this.text, this.textPaint);
rect.top = -getTextHeight(this.textPaint) / 2;
rect.bottom = 0;
rect.right = rect.left + getTextWidth(supText, this.subOrSupTextPaint);
canvas.drawText(supText, rect.left, rect.centerY() + getTextPaintBaseline(this.subOrSupTextPaint), this.subOrSupTextPaint);
}


if (this.subText != null) {
RectF rect = new RectF();
rect.left = startX + space + getTextWidth(this.text, this.textPaint);
rect.top = 0;
rect.bottom = getTextHeight(this.textPaint) / 2;
rect.right = rect.left + getTextWidth(subText, this.subOrSupTextPaint);
canvas.drawText(subText, rect.left, rect.centerY() + getTextPaintBaseline(this.subOrSupTextPaint), this.subOrSupTextPaint);
}

}

/**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/

public static float getTextPaintBaseline(Paint p) {
Paint.FontMetrics fontMetrics = p.getFontMetrics();
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}


public float getTextWidth() {

if (textPaint == null) {
return 0;
}

float width = 0;

width = getTextWidth(this.text, textPaint);

float subTextWidth = 0;
if (this.subText != null && subOrSupTextPaint != null) {
subTextWidth = getTextWidth(this.subText, subOrSupTextPaint) + space;
}

float supTextWidth = 0;
if (this.supText != null && subOrSupTextPaint != null) {
supTextWidth = getTextWidth(this.supText, subOrSupTextPaint) + space;
}
return width + Math.max(subTextWidth, supTextWidth);
}


//获取文本最小宽度(真实宽度)
private static int getTextRealWidth(String text, Paint paint) {
if (TextUtils.isEmpty(text)) return 0;
Rect rect = new Rect(); // 文字所在区域的矩形
paint.getTextBounds(text, 0, text.length(), rect);
//获取最小矩形,该矩形紧贴文字笔画开始的位置
return rect.width();
}

//获取文本最小高度(真实高度)
private static int getTextRealHeight(String text, Paint paint) {
if (TextUtils.isEmpty(text)) return 0;
Rect rect = new Rect(); // 文字所在区域的矩形
paint.getTextBounds(text, 0, text.length(), rect);
//获取最小矩形,该矩形紧贴文字笔画开始的位置
return rect.height();
}

//真实宽度 + 笔画左右两侧间隙(一般绘制的的时候建议使用这种,左右两侧的间隙和字形有关)
private static int getTextWidth(String text, Paint paint) {
if (TextUtils.isEmpty(text)) return 0;
return (int) paint.measureText(text);
}

//真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
private static int getTextHeight(Paint paint) {
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
return textHeight;
}


private static class Builder {

Paint subOrSupTextPaint = null;
Paint textPaint = null;
String subText = null;
String supText = null;
String text;
float space;

public Builder(String text, Paint textPaint, Paint subOrSupTextPaint) {
this.text = text;
this.textPaint = textPaint;
this.subOrSupTextPaint = subOrSupTextPaint;
}

public Builder subText(String subText) {
this.subText = subText;
return this;
}

public Builder supText(String supText) {
this.supText = supText;
return this;
}

public Builder textSpace(float space) {
this.space = space;
return this;
}

public TextInfo build() {
return new TextInfo(text, this.subText, this.supText, this.textPaint, this.subOrSupTextPaint, this.space);
}
}

}
private int[] colors = new int[]{
0xC0FFFFFF, 0x9fFFFFFF,
0x98FFFFFF, 0xA5FFFFFF,
0xB3FFFFFF, 0xBEFFFFFF,
0xCCFFFFFF, 0xD8FFFFFF,
0xE5FFFFFF, 0xFFFFFFFF};
private float[] positions = new float[]{
0f, 0.05f,
0.3f, 0.4f,
0.5f, 0.6f,
0.7f, 0.8f,
0.9f, 1f};

public void setShaderColors(int[] colors) {
this.colors = colors;
}

public void setShaderColors(int c) {
this.colors = new int[]{c, c, c, c,
c, c, c, c, c, c};
}

}

3.4 使用


        MathExpressTextView m1 = findViewById(R.id.math_exp_1);
MathExpressTextView m2 = findViewById(R.id.math_exp_2);
MathExpressTextView m3 = findViewById(R.id.math_exp_3);
MathExpressTextView m4 = findViewById(R.id.math_exp_4);


m1.setShaderColors(0xffFF4081);
m1.setText("2H","2","",10)
.appendMarkText("+")
.appendText("O","2","",10)
.appendMarkText("
=")
.appendText("
2H","2","",10)
.appendText("
O","","",10);

m2.setShaderColors(0xffff9922);
m2.setText("
2","","2",10)
.appendMarkText("
+")
.appendText("
5","","-1",10)
.appendMarkText("
=")
.appendText("
4.2","","",10);

m3.setShaderColors(0xffFFEAC4);
m3.setText("
H","2","0",10)
.appendMarkText("
+")
.appendText("
Cu","","+2",10)
.appendText("
O","","-2",10)
.appendMarkText("
==")
.appendText("
Cu","","0",10)
.appendText("
H","2","+1",10)
.appendText("
O","","-2",10);


m4.setText("
985","","GB",10)
.appendMarkText("
+")
.appendText("
211","","MB",10);

四、总结


相对来说本篇相对简单,没有过多复杂的计算。但是对于打算实现公式编辑器的项目,可参考本方案的设计思想:



  • 组合化:通过大公式,组合小公式,这样也方便使用语法树,提高通用性。

  • 对象化:单独描述单独片段

  • 规则化:对不同的片段进行规则化绘制,如appendMarkText方法


作者:时光少年
来源:juejin.cn/post/7303792111719792666
收起阅读 »

让Android开发Demo页面变得简单起来

Github: github.com/eekidu/devl… DevLayout DevLayout支持使用代码的方式,快速添加常用调试控件,无需XML,简化调试页面开发过程 背景 我们在开发组件库的时候,通常会开发一个Demo页面,用于展示或者调试该组件库...
继续阅读 »

Screenshot_20231122_163444.png


Github: github.com/eekidu/devl…


DevLayout


DevLayout支持使用代码的方式,快速添加常用调试控件,无需XML,简化调试页面开发过程


背景


我们在开发组件库的时候,通常会开发一个Demo页面,用于展示或者调试该组件库。
这种页面对UI的美观度要求很低,注重的是快速实现

使用XML布局方式开发会比较繁琐,该库会简化这一页面UI的开发流程:



  • 对常用的控件进行了封装,可以通过调用DevLayout的方法进行创建;

  • 并按流式布局或者线性布局的方式摆放到DevLayout中。


image.png

引入依赖


在Project的build.gradle在添加以下代码


allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}

在Module的build.gradle在添加以下代码


最新版本:


implementation 'com.github.eekidu:devlayout:Tag'

使用


DevLayout是一个ViewGr0up,你可以把它摆放到页面上合适的位置,然后通过调用它的方法来添加需要子控件。


//1、创建或者获取一个DevLaout实例
var mDevLayout = findViewById<DevLayout>(R.id.devLayout)


//2、调用方法添加调试控件

/**
* 添加功能按钮
*/

mDevLayout.addButton("功能1") {
//点击回调
}

/**
* 添加开关
*/

mDevLayout.addSwitch("开关1") { buttonView, isChecked ->
//状态切换回调
}

/**
* 添加SeekBar
*/

mDevLayout.addSeekBar("参数设置1") { progress ->
//进度回调
}.setMax(1000).setProgress(50).setEnableStep(true)//启用步进


/**
* 添加输入框
*/

mDevLayout.addEditor("参数设置") { inputText ->
textView.text = inputText
}

/**
* 单选,切换布局样式
*/

mDevLayout.addRadioGr0up("布局方式")
.addItem("流式布局") {
mDevLayout.setIsLineStyle(false)
}.addItem("线性布局") {
mDevLayout.setIsLineStyle(true)
}.setChecked(0)

/**
* 添加日志框
*/

mDevLayout.addLogMonitor()

/**
* 输出日志
*/

mDevLayout.log(msg)
mDevLayout.logI(msg)
mDevLayout.logD(msg)
mDevLayout.logW(msg)
mDevLayout.logE(msg)


/**
* 添加换行
*/

mDevLayout.br()
/**
* 添加分割线
*/

mDevLayout.hr()

//其他类型控件见Demo MainActivity.kt


耗时监控


我们调试代码一个重要的目的就是:发现耗时方法从而进行优化,DevLayout提供一个简易的耗时打印功能,实现如下:
大部分需要调试的代码,会在控件的回调中触发,那么对回调进行代理,在代理中监控原始回调的执行情况,就可以得到调试代码的执行耗时。


伪代码如下:


class ClickProxyListener(val realListener: OnClickListener) : OnClickListener {

override fun onClick(v: View) {
val startTime = Now()// 1、记录起始时间

realListener.onClick(v)//原始回调执行

val eTime = Now() - startTime//2、计算执行耗时
log("执行耗时:${eTime}")
}
}

//创建代理对象
val listenerProxy = ClickProxyListener(realListener)

由于控件种类很多,回调类的类型也都不一样,如何对形形色色的回调统一进行监控?


动态代理:封装了ProxyListener代理类,对原始回调进行代理


open class ProxyListener<T>(val realListener: T) : InvocationHandler {

override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any {
val startTime = Now()// 1、记录起始时间

val result = method.invoke(realListener, *(args ?: emptyArray()))//原始回调执行

val eTime = Now() - startTime//2、计算执行耗时
log("执行耗时:${eTime}")
return result
}
}

//动态创建代理对象
val listener = Proxy.newProxyInstance(_, listenerInterface , ProxyListener(realListener))

结合该例子感受动态代理的优点:




  • 灵活性:动态代理允许在运行时创建代理对象,而不需要在编译时指定具体的代理类。这使得代理对象可以根据需要动态地适应不同的接口和实现类。




  • 可扩展性:动态代理可以用于实现各种不同的功能,例如日志记录、性能监控、事务管理等。通过在代理对象的方法调用前后插入额外的逻辑,可以轻松地扩展现有的代码功能。




  • 解耦合:动态代理可以将代理逻辑与真实对象的实现逻辑分离。这样,代理对象可以独立于真实对象进行修改和维护,而不会影响到真实对象的代码。




  • 减少重复代码:通过使用动态代理,可以将一些通用的代码逻辑抽取到代理类中,从而减少代码的重复性。这样可以提高代码的可维护性和可读性。




  • 动态性:动态代理可以在运行时动态地创建代理对象,这意味着可以根据需要动态地修改代理对象的行为。这种灵活性使得动态代理在一些特定的场景下非常有用,例如AOP(面向切面编程)。




日志


日志是调试代码的重要方式,在某些场景下需要将日志输出到UI上,方便在设备没有连接Logcat,无法通过控制台监测日志时,也能对程序执行的中间过程或执行结果有一定的展示。


我们可以添加一个日志框到UI界面上,以此来展示Log信息,方式如下:


//添加日志框,默认尺寸,添加后也可以通过UI调整
mDevLayout.addLogMonitor()
mDevLayout.addLogMonitorSmall()
mDevLayout.addLogMonitorLarge()

//输出日志
mDevLayout.log(msg)
mDevLayout.logI(msg)
mDevLayout.logD(msg)
mDevLayout.logW(msg)
mDevLayout.logE(msg)

支持过滤:



  • 按等级过滤

  • 按关键词过滤,多关键字格式:key1,key2


同时,日志信息会在Logcat控制台输出,通过 tag:DevLayout 进行过滤查看。


image.png


最后


Github: github.com/eekidu/devl…


欢迎Star,如果有更好的优化方案,欢迎在github上提出,我们一起互相学习!


作者:Caohaikuan
来源:juejin.cn/post/7304182005285584933
收起阅读 »

工信部又出新规!爬坑指南

一、背景 工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。 二、整改 2.1 个...
继续阅读 »

一、背景


工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。


二、整改


2.1 个人信息保护


2.1.1 基本模式(无权限、无个人信息获取模式)


这次整改涉及到最大的一个点就是基本模式,基本模式指的是在用户选择隐私协议弹窗时,不能点击“不同意”即退出应用,而是需要给用户提供一个除联网功能外,无任何权限,无任何个人信息获取的模式且用户能正常使用。


这个说法有点抽象,我们来看下友商已经做好的案例。


腾讯视频



从腾讯视频的策略来看,用户第一次使用app,依旧会弹出一个“用户隐私协议”弹窗供用户选择,但是和以往不同的是,“不同意”按钮替换为了“不同意并进入基本功能模式”,用户点击“不同意并进入基本功能模式”则进入到一个简洁版的页面,只提供一些基本功能,当用户点击“进入全功能模式”,则再次弹出隐私协议弹窗。当杀死进程后,再次进入则直接进入基本模式。


网易云音乐



网易云音乐和腾讯视频的产品策略略有不同,在用户在一级授权弹窗点击“不同意”,会跳转至二级授权弹窗,当用户在二级弹窗上点击“不同意,进入基本功能模式”,才会进入基本功能页面,在此页面上点击“进入完整功能模式”后就又回到了二级授权页。当用户杀死进程,重新进入app时,还是会回到一级授权页。


网易云音乐比腾讯视频多了一个弹窗,也只是为了提升用户进入完整模式的概率,并不涉及到新规。


另外,B站、酷狗音乐等都已经接入了基本模式,有兴趣的伙伴可以自行下载体验。


2.1.2 隐私政策内容


如果app存在读取并传送用户个人信息的行为,需要检查其是否具备用户个人信息收集、使用规则,并明确告知读取和传送个人信息的目的、方式和范围。


判断权限是否有读取、修改、传送行为,如果有,需要在隐私协议中明文告知。


举个例子,app有获取手机号码并且保存在服务器,需要在协议中明确声明:读取并传送用户手机号码。


2.2 app权限调用


2.2.1 应用内权限调用



  1. 获取定位信息和生物特征识别信息


在获取定位信息以及生物特征识别信息时需要在调用权限前,单独向用户明示调用权限的目的,不能用系统权限弹窗替代。



如上图,申请位置权限,需要在申请之前,弹出弹窗供用户选择,用户同意调用后才可以申请位置权限。



  1. 其他权限


其他权限如上面隐私政策一样,需要在调用时,声明是读取、修改、还是传送行为,如下图



2.3 应用软件升级


2.3.1 更新


应用软件或插件更新应在用户授权的情况下进行,不能直接更新,另外要明确告知用户此行为包含下载和安装。


简单来说,就是在app进行更新操作时,需要用弹窗告知用户,是否更新应用,更新的确认权交给用户,并且弹窗上需要声明此次更新有下载和安装两个操作。如下图



2.4 应用签名


需要保证签名的真实有效性。


作者:付十一
来源:juejin.cn/post/7253610755126476857
收起阅读 »

聊聊Android中的DRM工具-Widevine

曾几何时,我一直好奇,像爱奇艺、腾讯视频、优酷这些视频平台是如何控制版权的,就比如,如何防止用户下载后发布到其他渠道,最近接触了DRM技术,瞬间就懂了。 DRM介绍 DRM(Digital Rights Management),即数字版权管理,是在数字内容交易...
继续阅读 »

曾几何时,我一直好奇,像爱奇艺、腾讯视频、优酷这些视频平台是如何控制版权的,就比如,如何防止用户下载后发布到其他渠道,最近接触了DRM技术,瞬间就懂了。


DRM介绍


DRM(Digital Rights Management),即数字版权管理,是在数字内容交易过程中,对知识产权进行保护的技术、工具和处理过程。它的目的是防止数字内容被未经授权的用户复制、修改和分发,以保护知识产权所有者的权益。在日常生活中,我们经常与 DRM 技术打交道。比如,电影上映前,我们不能在视频网站上观看电影,只能去电影院。这是内容提供(发行)商对自己的数字内容进行管理的一种结果。


DRM工作原理


先贴一张图,然后我们再做简单的说明drm工作原理


上图中,RAM想要给SHYAM传递小纸条,但因为距离较远,中间需要三个人进行传达,为了防止这三个人偷看小纸条内容,他们找来了HARI,HARI手上有一本密码本,每次RAM传递小纸条之前先找HARI拿到密码本,然后根据密码本的规则对小纸条内容进行加密,然后再将加密后的小纸条传递给SHYAM,这样,即使中间三个人偷看了小纸条,因为没有密码本,所以也看不懂纸条的内容。SHYAM收到小纸条后,再向HARI获取密码本,然后对小纸条内容进行解密,这样SHYAM就能看到原始内容了。


现在,我们把RAM看成是视频发行商,SHYAM看成是观众,HARI看成是版权管理商,就有了以下这种关系图


drm工作原理2


从上图中可以看出,我们想要向认证用户安全地发送一部电影。需要:



  • 向DRM厂商的服务器请求密码本

  • 然后使用密码本加密视频

  • 将电影视频发送给用户

  • 用户向DRM厂商的服务器请求密码本解密视频

  • 现在用户就可以观看电影了


这下视频版权管理是不是就一目了然了。但以上只是最初DRM的设计思想,现实中却无法正常运行,因为还没有解决多种分辨率的问题,这就需要对视频进行切片(ABR)和打包。


视频切片和打包


ABR: 通过使用ABR技术,电影可以被编码成不同的码率-分辨率组合(也称为码率阶梯)并被分割成小的视频块或者切片。每个视频切片包含几秒钟视频,可以被单独解码。


打包是指将电影分割成小的视频切片,并使用清单(manifest)或者播放列表对其进行描述。当用户想要播放电影的时候,他需要按照播放列表的信息播放。


根据可用带宽,播放器请求特定码率版本的视频切片,CDN响应后返回被请求切片。


drm工作原理3


这就结束了吗?不,这里面还存在很大的一个问题需要解决,视频的加密问题。


视频加密


前面说,视频发行商在发布视频时,需要向DRM服务商获取密码本,这里的密码本实际上是一种授权,就是说经过DRM服务商的授权,他才会对你的视频进行版权保护,并不是对视频内容进行加密,真正的视频加密还得涉及到密码学相关的技术,最常用的加密方式是AES,AES属于对称加密,这就涉及到密钥的保存。在DRM中,密钥也保存在DRM服务商手上,随着视频清单一起发送给视频播放器


drm工作原理4


好了,DRM的核心原理大概就是这些,如果想了解更详细的内容,可阅读下面的参考文献。


DRM厂商


上述DRM工作原理图中,有一个很重要的角色就是DRM服务商,目前主要有三大服务商,分别对应自己的DRM技术方案,分别是:




  • Apple FairPlay




  • Google Widevine




  • Microsoft PlayReady




国内爱奇艺最近也自主研发了自己的DRM解决方案:iQIYI DRM-S。而国内的视频平台几乎都是打包了所有的的DRM方案,以针对不同的平台和系统。以下是爱奇艺的整体DRM解决方案


爱奇艺drm方案


Widevine介绍


Widevine仅适用于基于Chromium的操作系统、Android设备以及其他Google相关设备和浏览器。


Widevine的安全级别



  • L1


在L1级别,提供了最高的安全性。内容在设备内进行解密,并使用硬件保护,以防止原始数据泄露。通常用于高质量视频和高分辨率的流媒体。获得L1认证的设备可以播放高质量的内容。像Amazon Prime Video和Netflix等流媒体服务需要L1安全性。如果在未获得认证的设备上观看,无法播放高清或超高清的高质量内容。



  • L2


L2具有较高的安全性,但不像L1那么严格。即使设备未获得L1认证,仍然可以播放内容。一些设备使用软件来保护数据。对于较低分辨率的视频和音乐内容,可能会使用L2。如果想要享受更高质量的内容,建议使用获得L1认证的设备,而不是L2。虽然L2可能不够满足要求,但某些内容仍然可能提供高质量的视频。因此,不能一概而论地认为必须使用L1。



  • L3


L3的安全级别最低。主要用于模拟器和一些旧设备等情况,内容保护相对较弱,分析和复制相对容易。此外,一些服务如Amazon Prime Video和Netflix也可能使用L3。虽然可以使用L3,但风险较高,不应期望高质量的内容。使用L3时需要谨慎考虑这些因素。


查看Widevine级别


可以使用DRM Info App查看设备的widevine安全级别,该App可以在Google Play上找到,文末贴了App的下载链接。大多数主流制造商的智能手机通常都支持L1至L3的某一个级别。如果发现您的设备不支持Widevine,那可能是制造商为了简化流程或者您的智能手机不符合标准。


image-20231121164459796


如果app打开闪退,说明设备并不支持Widevine。


测试Widevine功能


许多流媒体app都使用了Widevine,比如Youku、腾讯视频、IQIYI、YouTube、Netflix等,这里推荐使用Google的官方播放器ExoPlayer进行测试,文末提供下载链接


image-20231121165120972


(重点)在Android中集成Widevine


step1:获取Widevine源码


官网下载Widevine源码,注意,AOSP默认是没有Widevine源码的,需要手动集成,因为需要跟Google签订法律协议,然后由Google授权访问Widevine代码库,具体见Google官网流程。


step2:将源码放置到vendor目录下vendor/widevine/


image-20231121170544865


step3:添加编译配置


device/qcom/{product combo name}/BoardConfig.mk中添加


#这里设置的L3级别,L1级别需要跟Google签订协议,获取Keybox
BOARD_WIDEVINE_OEMCRYPTO_LEVEL := 3

device/qcom/{product combo name}/{product combo name}.mk中添加


PRODUCT_PROPERTY_OVERRIDES += drm.service.enabled=true
PRODUCT_PACKAGES += com.google.widevine.software.drm.xml \
com.google.widevine.software.drm
PRODUCT_PACKAGES += libwvdrmengine

vendor/qcom/proprietary/common/config/device-vendor.mk中修改


SECUREMSM += InstallKeybox
#L3级别需要删除oemcrypto库
#SECUREMSM += liboemcrypto
#SECUREMSM += liboemcrypto.a
SECUREMSM += libhdcpsrm

最后编译刷机,使用app工具验证即可,如果能显示Widevine级别,说明集成成功。


总结


好了,现在你应该彻底知道Widevine是怎么回事了


参考链接


中学生也能看懂的DRM


构建DRM系统的重要基石——EME、CDM、AES、CENC和密钥


爱奇艺DRM修炼之路


什么是Widevine?Widevine DRM详解


Google Widevine


Widevine安全级别查看app:


链接:pan.baidu.com/s/1lIJq-_eg…
提取码:fnk6


ExoPlayer:


链接:pan.baidu.com/s/1dUseWHIi…
提取码:nszh


作者:小迪vs同学
来源:juejin.cn/post/7303723984180101139
收起阅读 »

从小米14安装不上应用说起【适配64位】

一、原因 某天早上,同事突然对我说我换了小米14pro手机但是安装不了公司的打卡软件,怎么办呀。一时间,我也不知道原因,看到给我发的安装不上的截图陷入了沉思。随即打开在git仓库里找到这个项目,到本地编译打开,开始思考解决办法。 二、解决思路 从网上查询了一番...
继续阅读 »

一、原因


某天早上,同事突然对我说我换了小米14pro手机但是安装不了公司的打卡软件,怎么办呀。一时间,我也不知道原因,看到给我发的安装不上的截图陷入了沉思。随即打开在git仓库里找到这个项目,到本地编译打开,开始思考解决办法。


二、解决思路


从网上查询了一番,小米14pro 只支持安装64位的应用,可能是老项目没有做64位的适配。等到项目编译好,打开模块下的build.gradle文件,果然如此,没有做64位的适配。


ndk {
abiFilters 'armeabi', "x86", "x86_64"
}

针对64位做适配,一般都是适配so库,一般来说,如果你的项目中没有使用到so库或者C,C++代码,都是支持64位的。
这里再做下说明,ABI是Application Binary Interface(应用程序二进制接口)的缩写,在Android中,它指的是Android操作系统与设备硬件供应商提供的库之间的接口。ABI定义了程序各个组件在二进制级别上的交互方式,其中一个重要的方面是处理器使用的指令集架构。Android支持多种ABI以适应具有不同硬件架构的设备。



  • ARM(Advanced RISC Machine):

    • ARM是移动设备中常见的架构。

    • 变体:armv5、armv7-A、armv8-A(64位)等。



  • x86:

    • x86是台式机和笔记本电脑中常见的架构。

    • 变体:x86、x86_64(64位)。



  • MIPS(Microprocessor without Interlocked Pipeline Stages):

    • MIPS架构在过去在一些Android设备中被广泛使用,但近年来变得不那么常见。




好了,回归到正题,就要针对项目中这种情况处理so库了,因为这个老项目是从其他项目演变过来的,用不到这些so库,所以我的解决办法就是全部删除掉(当然要对项目中的源代码进行处理),再进行打包处理。
如果你们处理项目中的没有兼容的so库,推荐一个检测插件EasyPrivacy,接入这个就可以方便查看那些so库没有做适配了。
找到没有适配的so库之后,需要找到提供者获取最新兼容的so库或者找到相关官网看是否提供64位的so库。当然代码中还需要进行处理。


ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', "x86", "x86_64"
}

这样子打包的时候,在apk中的libs文件夹下就会出现四个对应的文件夹,里面就是对应的so库了。但是这样会增大包的体积。在android{}中配置如下代码,这样子打包之后就会出现四种包对应不同架构的包,这样子包的体积也会减小。


splits {
abi {
enable true
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' //select ABIs to build APKs for
universalApk true //generate an additional APK that contains all the ABIs
}
}

回归正题,当我把打好的包发给同事的时候,同事告诉我还是不行。思来想去,排除64位的问题,那么剩下的只有Android版本的问题了。新出的手机肯定是搭载最新的Android版本,目前Android 14 有什么新变动还没有了解到。
image.png
从官网看到,映入眼前的第一条就是这个,检查项目中的代码,发现targetSdkVersion 还是16,怪不得安装不上,至此所有问题解决。


作者:风罗伊曼
来源:juejin.cn/post/7303741345323221044
收起阅读 »

陪伴了 14 年的 API 下线了

hi 大家好,我是 DHL。就职于美团、快手、小米。 Android 开发者应该深有感触,近几年 Android 每次的更新,对开发者的影响都非常的大,而这次 Android 14 的更新,直接让陪伴我们多年的老朋友 overridePendingTransi...
继续阅读 »

hi 大家好,我是 DHL。就职于美团、快手、小米。


Android 开发者应该深有感触,近几年 Android 每次的更新,对开发者的影响都非常的大,而这次 Android 14 的更新,直接让陪伴我们多年的老朋友 overridePendingTransition 下线。


这篇文章主要想介绍一下我们的老朋友 overridePendingTransition,它陪伴了我们度过了 14 年,如今也完成了它的使命,现已功成身退,这个方法在 Android 14 中被废弃了。



在 2009 年的时候,正式将 overridePendingTransition 添加到 Android Eclair(2.0) 源码中,Android 开发者对它应该有一种熟悉有陌生的感觉吧,我们刚开始学 Android 写 Activity 跳转动画的时候,都接触过这个。


Intent intent = new Intent(B.this, C.class);
startActivity(intent);
overridePendingTransition(R.anim.fade_in, R.anim.fade_out);

这段代码对每个 Android 同学都非常熟悉,而且至今在项目里面,到处都有它的身影。如果我们要为 Antivity 添加进入或者退出动画,那么只需要在 startActivity() 或者 finish() 方法之后立即调用 overridePendingTransition 即可。


14 年后的今天,Android 14 的横空出世 overridePendingTransition 也完成了它的使命,在 Android 14 的源码中正式被废弃了,感兴趣的小伙伴,可以打开 Android 14 的源码看一下。



当得知它都被废弃了,确实感到有些意外,源码中推荐我们使用新方法 overrideActivityTransition 代替 overridePendingTransition


我还以为是什么更好的方法,结果推荐的方法更加的难用,为了一个虚有其表的功能,废弃了这个 API,还给开发者增加了很大的负担。


按照 Android 官方的解释和源码中的说明,废弃掉这个方法是因为在 Android 14 中引入了新的返回动画,而 overrideActivityTransition 方法不能和它很好的做兼容,所以需要用新的方法去替换。


什么是新的返回动画


比如使用返回手势可以在应用后面显示主屏幕的动画预览。



小伙伴们一起来评评这个功能实用性怎么样,为了这个功能废弃掉我们的老朋友,如果是你,你会这么做吗?另外我们在看看新的 API 的设计。



新的 API 相比于旧 API 多了一个参数 overrideType,一起来看看源码中是如何描述这个参数 overrideType


For example, if we want to customize the opening transition when launching 
Activity B which gets started from Activity A, we should call this method inside
onCreate with overrideType = OVERRIDE_TRANSITION_OPEN because the Activity B
will on top of the task. And if we want to customize the closing transition when
finishing Activity B and back to Activity A, since B is still is above A, we
should call this method in Activity B with overrideType = OVERRIDE_TRANSITION_CLOSE.

If an Activity has called this method, and it also set another activity animation
by Window#setWindowAnimations(int), the system will choose the animation set from
this method.

翻译一下就是,每次想使用过渡动画,都必须告诉系统 overrideType 使用什么参数,比如当我们从 Activity A 打开 Activity B 时,需要使用参数 overrideType = OVERRIDE_TRANSITION ,当我们从 Activity B 返回到 Activity A 时,需要使用参数 overrideType = OVERRIDE_TRANSITION_CLOSE


这个参数不是应该由系统自动来处理吗,开发者只需要关心参数 enterAnimexitAnim 即可,这明显没有带来任何好处,还给开发者增加了很多负担。


这只是其中一个改变,Android 开发者应该都深有感触,每次 Android 的更新,都有一堆无用的改变,还给开发者增加了很多负担,每次的适配都是一堆体力活,这样就导致了 App 对 SDK 的版本产生了强烈的依赖。


不过好在有经验的开发者,经历过一次有一次的适配之后,积累了经验,在新的项目中,会对大部分 Android API 进行封装,如果 API 有大的变化,不需要对整个项目进行修改。




全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!




我开了一个云同步编译工具(SyncKit),主要用于本地写代码,同步到远程设备,在远程设备上进行编译,最后将编译的结果同步到本地,代码已经上传到 Github,欢迎前往仓库 hi-dhl/SyncKit 查看。



作者:程序员DHL
来源:juejin.cn/post/7303878037590442022
收起阅读 »

大型 APP 的性能优化思路

做客户端开发都基本都做过性能优化,比如提升自己所负责的业务的速度或流畅性,优化内存占用等等。但是大部分开发者所做的性能优化可能都是针对中小型 APP 的,大型 APP 的性能优化经验并不会太多,毕竟大型 APP 就只有那么几个,什么是大型 APP 呢?以飞书来...
继续阅读 »

做客户端开发都基本都做过性能优化,比如提升自己所负责的业务的速度或流畅性,优化内存占用等等。但是大部分开发者所做的性能优化可能都是针对中小型 APP 的,大型 APP 的性能优化经验并不会太多,毕竟大型 APP 就只有那么几个,什么是大型 APP 呢?以飞书来说,他的业务有 im,邮箱,日历,小程序,文档,视频会议……等等,包体积就有大几百 M,像这种业务非常多且复杂的 APP 都可以认为是大型 APP。所以我在这篇文章主要讲一下大型 APP 是怎么做性能优化的,给大家在性能优化一块提供一个新的视角和启发。在这篇文章中,我主要会讲一下这两个主题:




  1. 大型 app 相比于中小型 app 在做性能优化时的异同点




  2. 大型 app 性能优化的思路




大型和小型应用性能优化的异同点


1.1 相同点


性能优化在本质上以及在优化维度上都是一样的。性能优化的本质是合理且充分的使用硬件资源,让程序表现的更好;并且都需要基于应用层、系统层、硬件层三个维度来进行优化


whiteboard_exported_image.png


1.2 不同点


针对系统层和硬件层的优化都是一样,有区别的主要是针对应用层的优化。


中小型 app 做业务和做性能优化的往往是同一个人,在做优化的时候,只需要考虑单个业务最优即可,我们只需要给这些业务充足的硬件资源(比如给更多的内存资源:缓存更多的数据,给更多的 cpu 资源:用更多的线程来执行业务逻辑,给更多的磁盘资源:缓存足够的本地数据),并且合理的使用就能让业务表现的更好。只要这些单个的业务性能表现好,那么这款 app 的整体性能品质是不错


whiteboard_exported_image-2.png


和中小型 APP 不同的是,大型 APP 业务多且复杂,各个业务的团队很可能都不在一个部门,不在同一个城市。在这种情况下,如果每个业务也去追求自己业务的性能最优,同样是在自己的业务中使用更多的线程,使用更多的缓存,使用更多 cpu 的方式来使自己业务表现更好,那么就会导致 APP 整体的性能急剧劣化。因此大型 APP 需要有一个专门团队来做性能优化的,这个团队需要脱离某一个具体业务,站在全局的视角来让 APP 整体表现更优。


whiteboard_exported_image-3.png


大型应用性能优化方案


总的来说由于资源是有限的,在中小型 APP 上,业务少,资源往往是充足的,我们做性能优化时往往考虑的是怎么将资源充分的发挥出来,而在大型 APP 上,资源往往是不足的,做性能优化时需要考虑即能充分发挥硬件资源,又需要进行合理的分配,当我们站在全局的视角来进行资源分配时,就需要考虑到这三个点:



  1. 如何管控业务对资源的使用

  2. 如何度量业务对资源的消耗

  3. 如何让业务在资源紧张时做出更优的策略


下面我会针对速度及流畅性优化、内存优化这两个方向,讲一讲针对这这三点的体现。


2.1 速度和流畅性优化:如何管控业务对资源的使用


在速度和流畅性方向,中小型 APP 只需要分析和优化主路径的耗时逻辑;将同步任务尽量优化成异步任务;多进行预加载等方案,即能起很好的优化效果。但是对于大型 APP 来说,异步任务往往非常多,cpu 往往都是打满的情况,这种情况下主路径得不到足够的 cpu 资源,导致速度变慢。所以大型 app 中一般都会对业务的异步任务,如启动阶段的预加载进行管控,因此需要预加载框架或者类似的框架,来收敛、管控、以及调度所有业务的预加载任务。我们来看一下在大型 APP 中,通用的预加载框架是怎么做的。


2.1.1 预加载框架


想要管控业务的预加载任务,我们需要考虑这两个点:




  1. 预加载任务的添加方式




  2. 预加载任务调度和管理的机制




  3. 预加载任务的添加方式




首先要将业务的预加载任务和业务进行解耦,要能做到即使该预加载任务不执行,也不会影响到业务的正常使用,并且将预加载任务封装成粒度最小的 task,然后直接将这些 task 丢到到预加载框架中,我们可以通过单例提供一个 addPreloadTask 方法,业务方只需要调用该接口,并传入预加载任务 task 以及一些属性及配置参数即可。将预加载任务添加到预加载框架后,业务方就不需要进行任何其他操作了,是否执行、什么时候执行,都交给预加载框架来管理。


whiteboard_exported_image-4.png




  1. 预加载任务调度时机




那么预加载框架对于添加进来的 task 如何调度呢?这就是一个预加载框架复杂的的地方的,我们可以有很多策略,比如这三种:



  1. 关键节点调度策略:比如各个生命周期阶段,页面渲染完成阶段等去执行,也可以在任务添加进来后立刻执行。

  2. 性能调度策略:比如判断 cpu 是否忙碌,温度是否过高,内存是否充足等,只有在性能较好的情况下才进行调度

  3. 用户行为调度策略:如果做的更复杂一些,还可以结合用户的行为指标,如该业务用户是否会使用,如果某一个用户从来不适用这个 app 里的这个功能,那么改业务添加进来的预加载任务就可以完全舍弃到,这里面可以用一些端智能的方案来精细化的控制预加载任务的调度


每种调度策略不是单独执行的,我们可以将各种策略整合起来,形成一套完善的调度策略。


whiteboard_exported_image-5.png


2.2 速度和流畅性优化:如何让业务在资源紧张时做出更优的策略


上面提到的是站在全局的视角,如何管控预加载任务的,除了预加载任务,还有很多其他的异步任务我们都可以用一些框架来规范化的管控起来,这里再举一个例子,对于大型 APP 来说,业务在使用的过程中很容易出现因为 cpu 或内存不足导致卡顿,响应慢等性能问题,所以在做性能优化时,是需要推动业务方在资源不足时,做出相应策略的,这个时候我们就需要降级框架来处理了。降级框架需要解决这两个问题:



  1. 性能指标的采集

  2. 降级任务的调度


2.2.1 降级框架




  1. 性能指标的采集




想要再资源紧张时让业务做出优化策略,那么对资源紧张的判断就是必不可少的一步。我们一般通过在程序运行过程中,采集设备性能指标来判断资源是否紧张,最基本的性能指标有 cpu 使用率,温度,Java 内存,机型等,除机型外其他性能指标一般都是以固定的频率进行采集,如 cpu 使用率可以 10s 采集一次,温度可以 30s 采集一次,java 内存可以 1 分钟采集一次,采集的频率需要考虑对性能的影响以及指标的敏感度,比如 cpu 的使用率采集,需要读取 proc/stat 的文件并解析,是有一定性能损耗的,所以我们在采集时,不能太频繁;温度的变化是比较慢的,我们采集的频率也可以长一些。降级框架需要整合这些性能指标的采集,减少各个业务自己采集造成不必要的性能损耗。


当降级框架采集到性能指标,并判断当前资源异常时,通用的做法是通知各个业务,业务收到通知后再进行降级。比如系统的 lowmemorykiller 机制,都是采用通知的方式。


whiteboard_exported_image-6.png


但是在大型 APP 中,仅仅将触发性能阈值的通知给到各个业务方,效果不会太好,因为业务方可能并不会去响应通知,或者个别业务响应了,但是其他业务不响应,依然效果不佳。无法管控业务是否进行降级,这显然不符合在大型 APP 做性能优化的思路,那么我们要怎么做呢?




  1. 降级任务的调度




添加任务:我们依然可以推动各个业务将降级的逻辑封装在 task 中,并且注册到降级框架中,并由降级框架来进行调度和管理。因为往降级框架注册 task 时,需要带上业务的名称,所以我们能也能清楚的知道,那些业务有降级处理逻辑,哪些业务没有,对于没有注册的业务,需要专门推动进行降级响应以及 task 的注册。


调度任务:和预加载框架一样,对于注册进来的 task,降级框架的任务调度要考虑清楚调度的时机,以 cpu 使用率为例,不同的设备下的阈值也是不一样的,高端机型可能 cpu 的使用率在 70%以上,app 还是流畅的,但是低端机在 50%以上就开始卡顿了,因此不同的机型需要根据经验值或者线上数据设置一个合理的阈值。当 cpu 到达这个阈值时,降级框架便开始执行注册到 cpu 列表中的降级任务,在执行降级任务时,不需要将队列里的 task 全部执行,我们可以分批执行,如果执行到某一批降级 task 时,cpu 恢复到阈值以下了,后面的降级 task 就可以不用在执行了。可以看到,通过降级框架,我们就可以站在全局的维度,去进行更好的管控,比如我们可以度量业务做降级任务的效果,给到一个评分,对于效果不好的,可以推动优化。


whiteboard_exported_image-7.png


2.3 内存优化:如何度量业务对资源的消耗


上面两个例子将的是在大型 app 中,如何管控业务对资源的使用,以及如何让业务在资源紧张时做出更优的策略的思路,我接着基于内存优化的方向,讲一讲如何度量业务对资源的消耗。


当 app 运行过程中,往往只能获得整体的内存的数据占用,没法获的各个业务消耗了多少内存的,因为各个业务的数据都是放在同一个堆中的,对于小型 app 来说这种情况并不是问题,因为就那么几个业务在使用内存,但是对于大型 app 来说就是一个问题了,有些业务为了自己性能指标能更好,会占用更多的内存,导致整体的内存占用过高。所以我们需要弄清每个业务到底使用了多少内存才能推动业务进行优化。


whiteboard_exported_image-10.png


我们可以线下通过分析 hprof 文件或者其他调试的方式来弄清楚每个 app 的内存占用,但是很多时候没有充足的时间在版本都去统计一下,或者即使统计了,也可能因为路径没覆盖全导致数据不准确。所以我们最好能通过线上监控的方式,就能统计到业务的内存消耗,并且在内存消耗异常的时候进行上报。


我在这里介绍一种思路。大部分的业务都是以 activity 呈现的,所以我们可以监听全局的 activity 创建,在业务的 onCreate 最前面统计一下 java 和 native 内存的大小,作为这个业务启动时的基准内存。然后在 acitvity 运行过程中,固定采集在当前 activity 下的内存并减去 onCreate 时的基准内存,我们就能度量出当前业务的一个内存消耗情况了。在该 acitvity 结束后,我们可以主动触发一下 gc,然后在和前面的基准内存 diff 一下,也能统计出该业务结束后的增量内存,理想情况下,增量内存应该是要小于零的,由于 gc 需要 cpu 资源,所以我们只需要开取小部分的采样率即可。


whiteboard_exported_image-8.png


当我们能在运行过程中,统计各个业务的内存消耗,那么就可以推动内存消耗高的业务进行优化,或者当某个版本的某个业务出现较大的劣化时,触发报警等。


除了上面提到的思路,我们也可以统计在业务使用过程中的触顶次数,计算出一个触顶率的指标,触顶及 java 内存占用达到一个阈值,比如 80%,我们就可以认为触顶了,对于触顶次数高的业务,同样也可以进行异常上报,然后推动业务方进行修改。这些数据和指标的统计,都是无侵入的,所以并不需要我们了解业务的细节。


如果我们想做的更细一些,还可以 hook 图片的创建,hook 集合的 add,remove 等方法,当监控到大图片和大集合时,打印堆栈,并将关键信息上报。在飞书,如果是低端机中,图片如果占用内存过大的,都会在 hook 方法中进行一些压缩或者降低质量的兜底处理。


总结


除了速度及流畅性,内存方向的优化外,还有其他方向的优化,如包体积,稳定性,功耗等,在大型 APP 上都要基于管控业务对资源的使用;度量业务对资源的消耗;让业务在资源紧张时做出更优的策略这三个方向去进行优化,这里我就不再一一展开讲了。


whiteboard_exported_image-9.png


当然我这里讲的优化思路并不是大型 app 做性能优化的全部,我讲的只是在做大型 app 的性能时相比于中小型 app 需要额外做的,并且也是效果最好的优化,这些方案在中小型 app 上可能并不需要。除了我这篇文章讲的内容外,还有很多优化的方案,这些方案不管是在大型 app 还是中小型 app 上都是通用的,比如深入了解业务,基于业务逻辑去做分析和优化,抓 trace,分析 trace 等等,或者基于系统层或者硬件层去做一些优化等等,这里就不再展开讲了。


作者:helson赵子健
来源:juejin.cn/post/7302740437529853963
收起阅读 »

鸿蒙开发,对于前端开发来说,究竟是福是祸呢?

提前声明: 先说好哈,不要一上来就开喷,好吧,不感兴趣的话你可以不用看下文直接划走,直接喷俺有点承受不住,心脏不好。如果你感兴趣,你可以先把这篇文章看完,看完后感觉俺讲的还挺有道理的那就不喷,如果讲的你认为啥也不是,那就往死里喷,喷不动了俺也加入。 唠叨唠叨 ...
继续阅读 »

提前声明: 先说好哈,不要一上来就开喷,好吧,不感兴趣的话你可以不用看下文直接划走,直接喷俺有点承受不住,心脏不好。如果你感兴趣,你可以先把这篇文章看完,看完后感觉俺讲的还挺有道理的那就不喷,如果讲的你认为啥也不是,那就往死里喷,喷不动了俺也加入。


唠叨唠叨


最近,鸿蒙开发的风头也吹到俺这里了,于是我便上网看了看,就以俺的知识面去聊一聊鸿蒙,究竟是个啥,有啥用呢。


在此之前,咱们可以先看个视频来大致了解一下鸿蒙系统究竟是干啥的,有啥好处:鸿蒙的官方定义哔哩哔哩bilibili(该视频为黑马的课程视频,原视频没暂时没找到,可跳到 03:46~12:1713:27~19:35 两个时间段)。


如果你看了这个视频的话,相信你对鸿蒙也有了一定的了解了。


为啥我想说鸿蒙呢



最近一段时间,总是有人在说一些(俺认为哈,别人我就管不着了哈)有些莫名其妙的话术:什么前端以死呀、鸿蒙就是个安卓套壳呀、前端的春天要来了呀、等等之类的。是真的死了吗,俺不这样认为,只是技术门槛提高了而已,毕竟市场他是活的,人它也是活的,是活的话他就有变的时候,你的技术不变,不去进行升级的话,那就会被现有的市场所淘汰。优胜劣汰这个道理俺相信你们每个人都懂,只是有些人不想去面对而已,仅此而已。



鸿蒙系统又是个啥


俺简单来说哈,其实就一句话:鸿蒙系统是全场景 、面向未来、万物物联的


如果这句话比较难理解,或者俺通过一张图让你更直观一点:


Snipaste_2023-11-15_17-23-22.png


如果你还是不理解的话,可以去华为官网看看官方对于鸿蒙系统的解释。


那鸿蒙系统的特点有啥



  1. 统一OS,弹性部署


一套操作系统,满足大大小小所有设备的需求,小到耳机,大到车机,智慧屏,手机等,让不同设备使用同一语言无缝沟通。



  1. 硬件互助,资源共享


搭载 HarmonyOS 的每个设备都不是孤立的,在系统层让多终端融为一体,成为“超级终端”,终端之间能力互助共享,带来无缝协同体验。手机可以连接一切设备,可以将这些设备看作一个整体,如当手机需要操作自家的音响时,直接在手机上简单动一动手指头就行了,就像操作手机上的喇叭一样方便。



  1. 一次开发,多端部署


开发者基于分布式应用框架,开发者只需要写一次逻辑代码,就可以部署在多种终端上,在多个设备上安装了。



  1. 应用自由跨端


HarmonyOS 原子化服务是轻量化服务的新物种,它提供了全新的服务和交互方式,可分可合,可流转,支持免安装等特性,能够让应用化繁为简,让服务触手可及。



  • 咱们来以一个例子理解一下:


假设咱们要用安卓操作系统去控制一台音响,这台音响有切歌功能、点歌功能、语音聊天功能,现在俺有点寂寞,需要音响陪我聊会天,俺只需要音响的语音聊天功能,但你必须要下载他的完整APP,并不能俺需要用啥功能就下载啥功能。而鸿蒙系统就可以做到。



  1. 用“简单”激活你的设备智能


HarmonyOS 是新一代智能终端操作系统。为不同设备的智能化、互联与协同提供了统一的语言。设备可实现一碰入网,无屏变有屏,操作可视化,一键直达原厂服务等全新功能。通过简单而智能的服务,实现设备智能化产业升级。


用安卓操作系统时,你需要下载设备对应的APP才能控制该设备,而鸿蒙操作系统,你直接将手机与设备上的芯片碰一碰,就可以直接通过手机来使用设备了。


小提示: 俺家也没几个鸿蒙相关的设备,具体的俺也不是特别清楚,这些都是俺从网上了解到的。手机能连接上设备的前提是该设备的厂家与华为达成了合作才行吧(好像是这样的)。但俺用的是华为手机,路由器也是华为的,就这两个华为设备从俺的体验上来说哈,那还是不错的。


可以与安卓做下对比



  1. 内核方面的对比


安卓系统:


是基于linux的宏内核设计 ,宏内核包含了操作系统绝大多数的功能和模块,而且这些功能和模块都具有最高的权限,只要一个模块出错,整个系统就会崩溃,这也是安卓系统容易崩溃的原因。好处就是系统开发难度低。


鸿蒙系统:


是微内核设计:微内核仅包括了操作系统必要的功能模块(任务管理、内存分配等)处在核心地位具有最高权限,其他模块不具有最高权限,也就是说其他模块出现问题,对于整个系统的运行是没有阻碍的。微内核稳定性很高。而且鸿蒙系统包含了两个内核,如果是手机APP是基于Linux内核,而如果是其他的硬件是基于LiteOS内核



  1. 运行速度的对比


安卓系统:


安卓程序不能与系统底层直接进行通信活动,是运行在虚拟机上的。如果虚拟机出了问题话的那系统就是卡住。


鸿蒙系统:


鸿蒙系统中的方舟编译器解决了这个问题的,任何由编译器编译的软件,是直接运行在操作系统中的,可以直接与系统底层进行通信。鸿蒙的运行速度更快



  1. 作为手机操作系统的对比


安卓和鸿蒙都是基于安卓开源项目(AOSP)进行开发的。


而安卓开源平台是可以在开源许可下自由使用和修改的。国内外很多手机厂商都根据这套代码制定了自己的操作系统,比如:三星、小米、魅族等。而华为也是基于这套开源体系,制定了鸿蒙操作系统。


鸿蒙操作系统的构成:



HarmonyOS = 安卓开放平台的开源代码 - GMS - 安卓UI + HMS + 鸿蒙UI + 分布式软总线 + 以Ability为核心的应用开发框架。




  1. 连接其他设备的对比


安卓系统:


安卓手机连接其他设备,不管从 app 开发方面,还有使用方面都非常麻烦,而且如果换一个第三方设备,还需要把发现,配对,连接,组合,验证的过程再次操作一遍。


鸿蒙系统:


但是鸿蒙就非常简单了,从 app 开发方面,只要写很少的代码就可以调用第三方硬件,从使用的角度来讲,不管是多少设备连在一起,鸿蒙的终极目标是都能像使用一台设备那样简单。


那鸿蒙究竟是不是安卓的套壳呢



网上有很多人说鸿蒙就是安卓的套壳,还用人说人家搞安卓开发的都是这样认为的。都不太看好鸿蒙,不要跟风,好吧。别人说是就是呀。你真的有去认真了解过吗。经过俺的一番捯饬后,俺大致的讲讲俺的理解哈。



其实吧,为啥有这么多人说鸿蒙就是安卓的套壳呢,归根结底呀,是这两家的 “祖宗” 其实是一家人,也就是安卓和鸿蒙都是基于安卓开源项目 AOSP 进行开发的。而且 AOSP 里的代码,是全球很多开发者共同维护开发的,华为也是该代码的提供者之一,任何人都是可以在开源许可协议下去自由使用和二次修改的。而华为也是基于这套开源体系,制定了鸿蒙操作系统。这就是为啥都说鸿蒙是安卓的套壳的原因了。


小提示: 可能会有人问俺 AOSP 又是啥东东,俺在网上找了一篇文章,你可以看看,了解一下:鸿蒙系统不是安卓系统?AOSP 为你揭秘! (baidu.com)


所以呢,不是套壳、不是套壳、不是套壳重要的事说三遍哈。你要是还是那样认为那话,那俺只能说,我嘞个豆!!!


就国家政策和市场形式


其实从央视力挺华为就可以看出了,我国对鸿蒙系统还是相当重视的(网传,鸿蒙系统会上交给国家,俺也不知道是真是假)。


就俺认为哈,代码这玩意都是老外搞出来的,一个操作系统能难倒他们,只是安卓和ios这两家独大,资历雄厚。可能有国外有好的操作系统出现,只是还没广为人知就已经被资本扼杀在摇篮里了。这又有谁知道呢。当然了这写只是俺的猜测而已。


如果一个国家的操作系统多了,其实也不利于社会的管理和发展,国家一定会主推一个操作系统,然后其他系统为辅,从而形成 “百家争鸣” 的形式。


另外哈,俺在招聘网上也查了看了一下,鸿蒙开发相关岗位的薪资大都与安卓开发平齐甚至有的还比安卓开发相关岗位的薪资要高得多(俺看到一家的鸿蒙开发的薪资,18~30K 16薪,说实话哈,俺是真的心动了)。


声明一下: 以上有关的国家和社会的话术,都是俺自我认为的、理解的,请广大网友不必太纠结其对错,更不要上升到国家层面去给予评论和回复。谢谢!


回归主题


回归主题: 鸿蒙开发,对于前端开发来说,究竟是福是祸呢?


看个人理解吧,俺认为哈,是福(俺已经开始学了)。就国内哈,如果明年华为推出的 HarmonyOS Next 将真的彻底抛弃 AOSP (华为的这个决定很大胆,这也是大部分的安卓开发者头痛的事,所以才会非常反感鸿蒙)。也就是说,明年,所有的安卓应用将不能在华为手机上使用,要想使用的话,就必须采用鸿蒙原生开发将应用改为鸿蒙应用程序。那你想想哈,我国有多少个应用,又有几个是用鸿蒙原生开发的或重构的,你再想想哈,这么多应用都要重构,那是不是这工作量非常之巨大,这么巨大的工作量,那公司是不是要招鸿蒙开发相关岗位了,薪资给少了你肯定不愿意去呀,那它公司又急需呀,那他的薪资待遇会不会被提高。那如果你会的话那你是不是就能上,那样的话害怕找不到工作。


这就是相当于前端开发的一个红利期,而且这个红利期至少会持续两三年势头不会淡。其实俺说它是前端开发的春天的话也不为过,至少是在国内哈,国外俺就不知道啦。


当然这还得等到明年华为推出的 HarmonyOS Next 是否真的彻底抛弃 AOSP ,如果是的话,那俺的认为就是对的。如果是假的话,那此上的一切都免谈,都是瞎扯淡。


上手试试



小提示哈: 如果你看完了上面的内容,你发现对鸿蒙开发产生了一定的好奇,你可以直接去官网注册个账号HarmonyOS应用开发官网 - 华为HarmonyOS打造全场景新服务,实名认证一下(俺建议采用银行卡的方式认证,这样通过认证更快),然后里面有在线的视频课程,它会带你具体了解如何开发鸿蒙原生应用。下面的内容你就可以忽略了。




Snipaste_2023-11-15_18-26-04.png


俺电脑上没装v16.19.1版本的node:俺用的是16.20.1的,不知道行不行,再装一个吧:


Snipaste_2023-11-15_18-34-21.png


路径与编译工具的安装地址是一致的:


Snipaste_2023-11-15_18-37-10.png


点击next,如果出现报红,选择第二个就可以了哈。


Snipaste_2023-11-15_18-42-37.png


创建个应用:


Snipaste_2023-11-15_18-46-29.png


Snipaste_2023-11-15_18-48-55.png


创建第一个应用 FirstApp


Snipaste_2023-11-15_18-55-34.png


咻咻等待一下的啦,让项目配置一下资源。


Snipaste_2023-11-15_19-04-17.png


第一次运行会有上图的提示信息,将其 × 了就可以看到 Hello World 效果了


Snipaste_2023-11-15_19-07-53.png


小改一下:


Snipaste_2023-11-15_19-09-37.png


使用模拟器:


Snipaste_2023-11-15_19-11-51.png


Snipaste_2023-11-15_19-15-31.png


登录后,选着P50机型模拟器调试:


Snipaste_2023-11-15_19-42-35.png



也不知道为啥,有时候就是无法用P50机型模拟器调试。后来俺还是用了本地模拟器。



作者:doudou_sir
来源:juejin.cn/post/7302254338855338003
收起阅读 »

Android进阶宝典 -- App线上网络问题优化策略

在我们App开发过程中,网络是必不可少的,几乎很难想到有哪些app是不需要网络传输的,所以网络问题一般都是线下难以复现,一旦到了用户手里就会碰到很多疑难杂症,所以对于网络的监控是必不可少的,针对用户常见的问题,我们在实际的项目中也需要添加优化策略。 1 网络的...
继续阅读 »

在我们App开发过程中,网络是必不可少的,几乎很难想到有哪些app是不需要网络传输的,所以网络问题一般都是线下难以复现,一旦到了用户手里就会碰到很多疑难杂症,所以对于网络的监控是必不可少的,针对用户常见的问题,我们在实际的项目中也需要添加优化策略。


1 网络的基础优化


对于一些主流的网络请求框架,像OkHttp、Retrofit等,其实就是对Http协议做了封装,我们在使用的时候常见的就是POST或者GET请求,如果我们是做客户端开发,知道这些基本的内容好像也可以写代码,但是真正碰到了线上网络问题,反而摸不到头脑,其实最大的问题还是对于网络方面的知识储备不足,所以文章的开始,我们先来点基础的网络知识。


1.1 网络连接的类型


其实对于网络的连接,我们常见的就是向服务端发起请求,服务端返回对应的响应,但是在同一时刻,只能有一个方向的数据传输,这种连接方式称为半双工通信。


类型描述举例
单工在通信过程中,数据只能由一方发送到另一方常见的例如UDP协议;Android广播
半双工在通信过程中,数据可以由一方A发送到另一方B,也可以从一方B发送到另一方A,但是同一时刻只能存在一方的数据传输常见的例如Http协议
全双工在任意时刻,都会存在A到B和B到A的双向数据传输常见的例如Socket协议,长连接通道

所以在Http1.0协议时,还是半双工的协议,因为默认是关闭长连接的,如果需要支持长连接,那么就需要在http头中添加字段:“Connection:Keep-Alive”;在Http 1.1协议时,默认是开启了长连接,如果需要关闭长连接,那么需要添加http请求头字段:“Connection:close”.


那么什么时候或者场景下,需要用到长连接呢?其实很简单,记住一点即可,如果业务场景中对于消息的即时性有要求时,就需要与服务端建立长连接,例如IM聊天,视频通话等场景。


1.2 DNS解析


如果伙伴们在项目中有对网络添加trace日志,除了net timeout这种超时错误,应该也看到过UnknowHostException这种异常,这是因为DNS解析失败,没有解析获取到服务器的ip地址。


像我们在家的时候,手机或者电脑都会连接路由器的wifi,而路由器是能够设置dns服务器地址的,


image.png


但是如果设置错误,或者被攻击篡改,就会导致DNS解析失败,那么我们app的网络请求都会出现异常,所以针对这种情况,我们需要加上自己的DNS解析策略。


首先我们先看一个例子,假设我们想要请求百度域名获取一个数据,例如:


object HttpUtil {

private const val BASE_URL = "https://www.baidu.comxx"

fun initHttp() {
val client = OkHttpClient.Builder()
.build()
Request.Builder()
.url(BASE_URL)
.build().also {

kotlin.runCatching {
client.newCall(it).execute()
}.onFailure {
Log.e("OkHttp", "initHttp: error $it ")
}

}
}
}

很明显,百度的域名是错误的,所以在执行网络请求的时候就会报错:


java.net.UnknownHostException: Unable to resolve host "www.baidu.comxx": No address associated with hostname

所以一旦我们的域名被劫持修改,那么整个服务就会处于宕机的状态,用户体感就会很差,因此我们可以通过OkHttp提供的自定义DNS解析器来做一个小的优化。


public interface Dns {
/**
* A DNS that uses {@link InetAddress#getAllByName} to ask the underlying operating system to
* lookup IP addresses. Most custom {@link Dns} implementations should delegate to this instance.
*/

Dns SYSTEM = hostname -> {
if (hostname == null) throw new UnknownHostException("hostname == null");
try {
return Arrays.asList(InetAddress.getAllByName(hostname));
} catch (NullPointerException e) {
UnknownHostException unknownHostException =
new UnknownHostException("Broken system behaviour for dns lookup of " + hostname);
unknownHostException.initCause(e);
throw unknownHostException;
}
};

/**
* Returns the IP addresses of {@code hostname}, in the order they will be attempted by OkHttp. If
* a connection to an address fails, OkHttp will retry the connection with the next address until
* either a connection is made, the set of IP addresses is exhausted, or a limit is exceeded.
*/

List<InetAddress> lookup(String hostname) throws UnknownHostException;
}

我们看下源码,lookup方法相当于在做DNS寻址,一旦发生异常那么就会抛出UnknownHostException异常;同时内部还定义了一个SYSTEM方法,在这个方法中会通过系统提供的InetAddress类进行路由寻址,同样如果DNS解析失败,那么也会抛出UnknownHostException异常。


所以我们分两步走,首先使用系统能力进行路由寻址,如果失败,那么再走自定义的策略。


class MyDNS : Dns {


override fun lookup(hostname: String): MutableList<InetAddress> {

val result = mutableListOf<InetAddress>()
var systemAddressList: MutableList<InetAddress>? = null
//通过系统DNS解析
kotlin.runCatching {
systemAddressList = Dns.SYSTEM.lookup(hostname)
}.onFailure {
Log.e("MyDNS", "lookup: $it")
}

if (systemAddressList != null && systemAddressList!!.isNotEmpty()) {
result.addAll(systemAddressList!!)
} else {
//系统DNS解析失败,走自定义路由
result.add(InetAddress.getByName("www.baidu.com"))
}

return result
}
}

这样在www.baidu.comxx 解析失败之后,就会使用www.baidu.com 域名替换,从而避免网络请求失败的问题。


1.3 接口数据适配策略


相信很多伙伴在和服务端调试接口的时候,经常会遇到这种情况:接口文档标明这个字段为int类型,结果返回的是字符串“”;或者在某些情况下,我需要服务端返回一个空数组,但是返回的是null,对于这种情况,我们在数据解析的时候,无论是使用Gson还是Moshi,都会解析失败,如果处理不得当,严重的会造成崩溃。


所以针对这种数据格式不匹配的问题,我们可以对Gson简单做一些适配处理,例如List类型:


class ListTypeAdapter : JsonDeserializer<List<*>> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
)
: List<*> {
return try {
if (json?.isJsonArray == true) {
Gson().fromJson(json, typeOfT)
} else {
Collections.EMPTY_LIST
}
} catch (e: Exception) {
//
Collections.EMPTY_LIST
}
}
}

如果json是List数组类型数据,那么就正常将其转换为List数组;如果不是,那么就解析为空数组。


class StringTypeAdapter : JsonDeserializer<String> {

override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
)
: String {
return try {
if (json?.isJsonPrimitive == true) {
Gson().fromJson(json, typeOfT)
} else {
""
}
} catch (e: Exception) {
""
}
}
}

对于String类型字段,首先会判断是否为基础类型(String,Number,Boolean),如果是基础类型那么就正常转换即可。


GsonBuilder()
.registerTypeAdapter(Int::class.java, IntTypeAdapter())
.registerTypeAdapter(String::class.java, StringTypeAdapter())
.registerTypeAdapter(List::class.java, ListTypeAdapter())
.create().also {
GsonConverterFactory.create(it)
}

这样在创建GsonConverterFactory时,就可以使用我们的策略来进行数据适配,但是在测试环境下,我们不建议这样使用,因为无法发现服务端的问题,在上线之后为了规避线上问题可以使用此策略。


2 HTTPS协议


http协议与https协议的区别,就是多了一个“s”,可别小看这一个“s”,它能够保证http数据传输的可靠性,那么这个“s”是什么呢,就是SSL/TLS协议。


image.png


从上图中看,在进入TCP协议之前会先走SSL/TLS协议.


2.1 对称加密和非对称加密


既然Https能保证传输的可靠性,说明它对数据进行了加密,以往http协议数据的传输都是明文传输,数据极容易被窃取和冒充,因此后续优化中,对于数据进行了加密传输,才有了Https协议诞生。


常见的加密手段有两种:对称加密和非对称加密。


2.1.1 对称加密


首先对称加密,从名字就能知道具体的原理,看下图:


image.png


对称加密和解密的密钥是一把钥匙,需要双方约定好,发送方通过秘钥加密数据,接收方使用同一把秘钥解密获取传递的数据。


所以使用对称加密非常简单,解析数据很快,但是安全性比较差,因为双方需要约定同一个key,key的传输有被劫持的风险,而统一存储则同样存在被攻击的风险。


所以针对这种情况,应运而生出现了非对称加密。


2.1.2 非对称加密


非对称加密会有两把钥匙:私钥 + 公钥,对于公钥任何人都可以知道,发送方可以使用公钥加密数据,而接收方可以用只有自己知道的私钥解密拿到数据。


image.png


那么既然公钥所有人都知道,那么能够通过公钥直接推算出私钥吗?答案是目前不可能,未来可能会,得看全世界的密码学高手或者黑客能否解决这个问题。


总结一下两种加密方式的优缺点:


加密类型优点缺点
对称加密流程简单,解密速度快不安全,秘钥管理有风险
非对称加密私钥只有自己知道流程繁琐,解密速度慢

2.2 公钥的安全保障


通过2.1小节对于非对称加密的介绍,虽然看起来安全性更高了一些,但是对于公钥的传递有点儿太理想化,我们看下面的场景。


image.png


如果公钥在传输的过程中被劫持,那么发送方拿到的是黑客的公钥,后续所有的数据传输都被劫持了,所以问题来了,如何保证发送方拿到的公钥一定是接收方的?


举个简单的例子:我们在马路上捡到了一张银行卡,想把里面的钱取出来,那么银行柜台其实就是接收方,银行卡就是公钥,那么银行就会直接把钱给我们了吗?肯定不可以,要么需要身-份-证,要么需要密码,能够证明这个银行卡是我们自己的,所以公钥的安全性保证就是CA证书(可以理解为我们的身-份-证)。


那么首先接收方需要办一张身-份-证,需要通过CA机构生成一个数字签名,具体生成的规则如下:


image.png


那么最终发送给接收方的就是如下一张数字证书,包含的内容有:数字签名 + 公钥 + 接收方的个人信息等。


image.png


那么发送方接收到数字证书之后,就会检查数字证书是否合法,检测方式如下:


image.png


如果不是办的假证,这种可能性几乎为0,因为想要伪造一个域名的数字签名,根本不可能,CA机构也不是吃干饭的,所以只要通过证书认证了,那么就能保证公钥的安全性。


image.png


2.3 Https的传输流程


其实一个Https请求,中间包含了2次Http传输,假如我们请求http://www.baidu.com 具体流程如下:


(1)客户端向服务端发起请求,要访问百度,那么此时与百度的服务器建立连接;


(2)此时服务端有公钥和私钥,公钥可以发送给客户端,然后给客户端发送了一个SSL证书,其中包括:CA签名、公钥、百度的一些信息,详情可见2.2小节最后的图;


(3)客户端在接收到SSL证书后,对CA签名解密,判断证书是否合法,如果不合法,那么就断开此次连接;如果合法,那么就生成一个随机数,作为数据对称加密的密钥,通过公钥加密发送到服务端。


(4)服务端接收到了客户端加密数据后,通过私钥解密,拿到了对称加密的密钥,然后将百度相关数据通过对称加密秘钥加密,发送到客户端。


(5)客户端通过解密拿到了服务端的数据,此次请求结束。


其实Https请求并不是完全是非对称加密,而是集各家之所长,因为对称加密密钥传递有风险,因此前期通过非对称加密传递对称加密密钥,后续数据传递都是通过对称加密,提高了数据解析的效率。


但是我们需要了解的是,Https保障的只是通信双方当事人的安全,像测试伙伴通过Charles抓包这种中间人攻击方式,还是会导致数据泄露的风险,因为通过伪造证书或者不受信任的CA就可以实现。


作者:layz4android
来源:juejin.cn/post/7276368438146924563
收起阅读 »

华为鸿蒙app开发,真的遥遥领先?

前言 最近刷头条,刷到很多开始鸿蒙系统app开发者说 鸿蒙系统要崛起了 属于国家意志。于是我也在周五空闲时间去华为官网学习一下,体验一下遥遥领先的感觉。 developer.huawei.com/ 官网下载下载DevEco Studio 下载流程就不用细说了 ...
继续阅读 »

前言


最近刷头条,刷到很多开始鸿蒙系统app开发者说 鸿蒙系统要崛起了 属于国家意志。于是我也在周五空闲时间去华为官网学习一下,体验一下遥遥领先的感觉。
developer.huawei.com/ 官网下载下载DevEco Studio


下载流程就不用细说了 借鉴一下别人的文章,主要核心在于按照官网学习了一个ToDo的例子


鸿蒙OS应用开发初体验


启动页面


image.png


Setup


image.png


image.png



HarmonyOS-SDK:鸿蒙操作系统软件开发工具包



  • Previewer:预览器

  • Toolchains:工具链



OpenHarmony-SDK:开源鸿蒙操作系统软件开发工具包




  • ArkTS:鸿蒙生态的应用开发语言。

  • JS:JavaScript

  • Previewer:预览器

  • Toolchains:工具链



image.png


Create Project


image.png image.png


配置工程


image.png 项目名称、包名、存储路径、编译SDK版本、模型,语言、设备类型等。


工程目录结构


image.png



  • AppScope:存放应用全局所需要的资源文件。

  • entry:应用主模块,存放HarmonyOS应用的代码、资源等。

  • on_modules:工程依赖包,存放工程依赖的源文件。

  • build-profile.json5是工程级配置信息,包括签名、产品配置等。

  • hvigorfile.ts是工程级编译构建任务脚本,hvigor是基于任务管理机制实现的一款全新的自动化构建工具,主要提供任务注册编排,工程模型管理、配置管理等核心能力。

  • oh-package.json5是工程级依赖配置文件,用于记录引入包的配置信息


TODO例子


这里我我干掉了初始代码 实现了一个TODO例子 源码贴出来了


image.png



import ArrayList from '@ohos.util.ArrayList'
@Entry
@Component
struct Index {
@State message: string = 'Hello World'
private taskList:Array<String>=[
'吃饭',
'睡觉',
'遛娃',
'学习'
]
build() {
Column() {
Text('待办')
.fontSize(50)
.fontWeight(FontWeight.Bold)
.align(Alignment.Start)
ForEach(this.taskList,(item)=>{
ToDoItem({ content: item })
})

}.height('100%')
.width('100%')
.backgroundColor('#e6e6e6')

}
}

@Component
struct ToDoItem {
private content: string;
@State isComplete: boolean = false;
@State isClicked: boolean = false;
build() {
Row() {
Image($r('app.media.app_icon'))
.width(20)
.margin(10)
Text(this.content)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(Color.Black)
.opacity(this.isComplete ? 0.4 : 1)
.decoration({ type: this.isComplete ? TextDecorationType.Underline : TextDecorationType. })
}.borderRadius(24)
.width('100%')
.padding(20)
.backgroundColor(this.isClicked ? Color.Gray : Color.White)
.margin(10)
.onClick(
()=>{
this.isClicked = true; // 设置点击状态为true
setTimeout(() => {
this.isClicked = false; // 0.5秒后恢复点击状态为false
}, 500);
this.isComplete=!this.isComplete
}
)
}
}

总结


在模拟器上 点击啥的效果还好 但是在我华为p40上的真机运行效果真的点击效果响应太慢了吧。本人也是华为手机的爱好者,但这一次真的不敢苟同谁敢用这样的平台开发app。有深入学习的大佬指点一下,望花粉勿喷。


作者:阡陌昏晨
来源:juejin.cn/post/7302070112639385651
收起阅读 »

鸿蒙OS应用开发初体验

什么是HarmonyOS? HarmonyOS(鸿蒙操作系统)是华为公司开发的一款基于微内核的分布式操作系统。它是一个面向物联网(IoT)时代的全场景操作系统,旨在为各种类型的设备提供统一的操作系统平台和开发框架。HarmonyOS 的目标是实现跨设备的无缝协...
继续阅读 »

什么是HarmonyOS?


HarmonyOS(鸿蒙操作系统)是华为公司开发的一款基于微内核的分布式操作系统。它是一个面向物联网(IoT)时代的全场景操作系统,旨在为各种类型的设备提供统一的操作系统平台和开发框架。HarmonyOS 的目标是实现跨设备的无缝协同和高性能。


DevEco Studio



对标Android Studio,开发鸿蒙OS应用的IDE。



启动页面


image.png


Setup


image.png


image.png



HarmonyOS-SDK:鸿蒙操作系统软件开发工具包



  • Previewer:预览器

  • Toolchains:工具链



OpenHarmony-SDK:开源鸿蒙操作系统软件开发工具包




  • ArkTS:鸿蒙生态的应用开发语言。

  • JS:JavaScript

  • Previewer:预览器

  • Toolchains:工具链



image.png


Create Project


image.png
image.png


配置工程


image.png
项目名称、包名、存储路径、编译SDK版本、模型,语言、设备类型等。


工程目录结构


image.png



  • AppScope:存放应用全局所需要的资源文件。

  • entry:应用主模块,存放HarmonyOS应用的代码、资源等。

  • on_modules:工程依赖包,存放工程依赖的源文件。

  • build-profile.json5是工程级配置信息,包括签名、产品配置等。

  • hvigorfile.ts是工程级编译构建任务脚本,hvigor是基于任务管理机制实现的一款全新的自动化构建工具,主要提供任务注册编排,工程模型管理、配置管理等核心能力。

  • oh-package.json5是工程级依赖配置文件,用于记录引入包的配置信息。


Device Manager


image.png


创建好的模拟器会出现在这里。
image.png


启动模拟器之后,会在设备列表中出现。


image.png


编译运行


image.png
编译运行,可以从通知栏看到输出的文件并不是apk,而是hap(Harmony Application Package的缩写)。是鸿蒙操作系统设计的应用程序包格式。


image.png
.hap 文件包含了应用程序的代码、资源和元数据等信息,用于在 HarmonyOS 设备上安装和运行应用程序。


image.png


整体开发流程跟Android基本无差,所以熟悉Android开发的同学上手基本没啥难度。


ArkTS



ArkTS是鸿蒙生态的应用开发语言。它在保持TypeScript(简称TS)基本语法风格的基础上,对TS的动态类型特性施加更严格的约束,引入静态类型。同时,提供了声明式UI、状态管理等相应的能力,让开发者可以以更简洁、更自然的方式开发高性能应用。
developer.harmonyos.com/cn/develop/…



最简单例子:


@Entry
@Component
struct Index {
@State message: string = 'Hello World'

build() {
Row() {
Column() {
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
}
.width('100%')
}
.height('100%')
}
}

看起来非常简洁,采用的是声明式UI,写过Flutter的同学对声明式UI应该不会陌生。从最简单的例子初步了解下基本语法:



  • 装饰器,用于装饰类、结构、方法以及变量,并赋予其特殊的含义。如@Entry、@Component、@State都是装饰器。

  • 自定义组件:可复用的UI单元,可组合其他组件,如上述被@Component装饰的stuct Index。

  • UI 描述:以声明式的方式来描述UI的结构,如上述的build()方法中的代码块。

  • 系统组件:ArkUI框架中默认内置的基础和容器组件,可直接被开发者调用,比如示例中的Row、Column、Text。

  • 属性方法:组件可以通过链式调用配置多项属性,如fontSize()、width()、height()、backgroundColor()等。

  • 事件方法:组件可以通过链式调用设置多个事件的响应逻辑,本例代码不涉及,可以进一步学习文档。


这里就不是Android熟悉的java或kotlin语言了,编程语言变成了类JavaScript的前端语言,这意味着我们需要适应用前端的思想去开发鸿蒙应用,比如状态管理。


总结


本文纯初体验遥遥领先背后的鸿蒙操作系统,基于开发者平台提供的IDE、鸿蒙生态的开发语言ArkTS,通过模拟器运行起来了鸿蒙OS版HelloWorld。对于已经有移动开发经验的同学来说上手可以说非常快,官方文档也非常详尽,ArkTS语法也非常简洁易学,如果大家对华为生态的应用开发感兴趣或者想深入学习借鉴华为做OS和物联网的思路,鸿蒙系统就是一个标杆。


作者:巫山老妖
来源:juejin.cn/post/7295576148363886631
收起阅读 »

扒一扒抖音是如何做线程优化的

背景 最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。 问题 创建线程卡顿 在...
继续阅读 »

背景


最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。


问题


创建线程卡顿


在Java中,真正的内核线程被创建是在执行 start函数的时候, nativeCreate的具体流程可以参考我之前的一篇分析文章 Android虚拟机线程启动过程解析 。这里假设你已经了解了,我们可以可以知道 start()函数底层涉及到一系列的操作,包括 栈内存空间分配、内核线程创建 等操作,这些操作在某些情况下可能出现长耗时现象,比如由于linux系统中,所有系统线程的创建在内核层是由一个专门的线程排队实现,那么是否可能由于队列较长同时内核调度出现问题而出现长耗时问题? 具体的原因因为没有在线下复现过此类问题,因此只能大胆猜测,不过在线上确实收集到一些case, 以下是线上收集到一个阻塞现场样本:



那么是不是不要直接在主线程创建其他线程,而是直接使用线程池调度任务就没有问题? 让我们看下 ThreadPoolExecutor.execute(Runnable command)的源码实现



从文档中可以知道,execute函数的执行在很多情况下会创建(JavaThread)线程,并且跟踪其内部实现后可以发现创建Java线程对象后,也会立即在当前线程执行start函数。



来看一下线上收集到的一个在主线程使用线程池调度任务依旧发生卡顿的现场。



线程数过多的问题


在ART虚拟机中,每创建一个线程都需要为其分配独立的Java栈空间,当Java层未显示设置栈空间大小时,native层会在FixStackSize函数会分配默认的栈空间大小.



从这个实现中,可以看出每个线程至少会占用1M的虚拟内存大小,而在32位系统上,由于每个进程可分配的用户用户空间虚拟内存大小只有3G,如果一个应用的线程数过多,而当进程虚拟内存空间不足时,创建线程的动作就可能导致OOM问题.



另一个问题是某些厂商的应用所能创建的线程数相比原生Android系统有更严格的限制,比如某些华为的机型限制了每个进程所能创建的线程数为500, 因此即使是64位机型,线程数不做控制也可能出现因为线程数过多导致的OOM问题。


优化思路


线程收敛


首先在一个Android App中存在以下几种情况会使用到线程



  • 通过 Thread类 直接创建使用线程

  • 通过 ThreadPoolExecutor 使用线程

  • 通过 ThreadTimer 使用线程

  • 通过 AsyncTask 使用线程

  • 通过 HandlerThread 使用线程


线程收敛的大致思路是, 我们会预先创建上述几个类的实现类,并在自己的实现类中做修改, 之后通过编译期的字节码修改,将App中上述使用线程的地方都替换为我们的实现类。


使用以上线程相关类一般有几种方式:



  1. 直接通过 new 原生类 创建相关实例

  2. 继承原生类,之后在代码中 使用 new 指令创建自己的继承类实例


因此这里的替换包括:



  • 修改类的继承关系,比如 将所有 继承 Thread类的地方,替换为 我们实现 的 PThread

  • 修改上述几种类直接创建实例的地方,比如将代码中存在 new ThreadPoolExecutor(..) 调用的地方替换为 我们实现的 PThreadPoolExecutor


通过字码码修改,将代码中所有使用线程的地方替换为我们的实现类后,就可以在我们的实现类做一些线程收敛的操作。


Thread类 线程收敛


在Java虚拟机中,每个Java Thread 都对应一个内核线程,并且线程的创建实际上是在调用 start()函数才开始创建的,那么我们其实可以修改start()函数的实现,将其任务调度到指定的一个线程池做执行, 示例代码如下


class ThreadProxy : Thread() {
override fun start() {
SuperThreadPoolExecutor.execute({
this@ThreadProxy.run()
}, priority = priority)
}
}

线程池 线程收敛


由于每个ThreadPoolExecutor实例内部都有独立的线程缓存池,不同ThreadPoolExecutor实例之间的缓存互不干扰,在一个大型App中可能存在非常多的线程池,所有的线程池加起来导致应用的最低线程数不容小视。


另外也因为线程池是独立的,线程的创建和回收也都是独立的,不能从整个App的任务角度来调度。举个例子: 比如A线程池因为空闲正在释放某个线程,同时B线程池确可能正因为可工作线程数不足正在创建线程,如果可以把所有的线程池合并成 一个统一的大线程池,就可以避免类似的场景。


核心的实现思路为:



  1. 首先将所有直接继承 ThreadPoolExecutor的类替换为 继承 ThreadPoolExecutorProxy,以及代码中所有new ThreadPoolExecutor(..)类 替换为 new ThreadPoolExecutorProxy(...)

  2. ThreadPoolExecutorProxy 持有一个 大线程池实例 BigThreadPool ,该线程池实例为应用中所有线程池共用,因此其核心线程数可以根据应用当前实际情况做调整,比如如果你的应用当前线程数平均是200,你可以将BigThreadPool 核心线程设置为150后,再观察其调度情况。

  3. 在 ThreadPoolExecutorProxy 的 addWorker 函数中,将任务调度到 BigThreadPool中执行



AsyncTask 线程收敛


对于AsyncTask也可以用同样的方式实现,在execute1函数中调度到一个统一的线程池执行



public abstract class AsyncTaskProxy<Params,Progress,Result> extends AsyncTask<Params,Progress,Result>{

private static final Executor THREAD_POOL_EXECUTOR = new PThreadPoolExecutor(0,20,
3, TimeUnit.MILLISECONDS,
new SynchronousQueue<>(),new DefaultThreadFactory("PThreadAsyncTask"));


public static void execute(Runnable runnable){
THREAD_POOL_EXECUTOR.execute(runnable);
}

/**
* TODO 使用插桩 将所有 execute 函数调用替换为 execute1
* @param params The parameters of the task.
* @return This instance of AsyncTask.
*/

public AsyncTask execute1(Params... params) {
return executeOnExecutor(THREAD_POOL_EXECUTOR,params);
}


}

Timer类


Timer类一般项目中使用的地方并不多,并且由于Timer一般对任务间隔准确性有比较高的要求,如果收敛到线程池执行,如果某些Timer类执行的task比较耗时,可能会影响原业务,因此暂不做收敛。


卡顿优化


针对在主线程执行线程创建可能会出现的阻塞问题,可以判断下当前线程,如果是主线程则调度到一个专门负责创建线程的线程进行工作。


    private val asyncExecuteHandler  by lazy {
val worker = HandlerThread("asyncExecuteWorker")
worker.start()
return@lazy Handler(worker.looper)
}


fun execute(runnable: Runnable, priority: Int) {
if (Looper.getMainLooper().thread == Thread.currentThread() && asyncExecute
){
//异步执行
asyncExecuteHandler.post {
mExecutor.execute(runnable,priority)
}
}else{
mExecutor.execute(runnable, priority)
}

}

32位系统线程栈空间优化


在问题分析中的环节中,我们已经知道 每个线程至少需要占用 1M的虚拟内存,而32位应用的虚拟内存空间又有限,如果希望在线程这里挤出一点虚拟内存空间来,可以参考微信的一个方案, 其利用PLT hook需改了创建线程时的栈空间大小。


而在另一篇 juejin.cn/post/720930… 技术文章中,也介绍了另一个取巧的方案 :在Java层直接配置一个 负值,从而起到一样的效果



OOM了? 我还能再抢救下!


针对在创建线程时由于内存空间不足或线程数限制抛出的OOM问题,可以做一些兜底处理, 比如将任务调度到一个预先创建的线程池进行排队处理, 而这个线程池核心线程和最大线程是一致的 因此不会出现创建线程的动作,也就不会出现OOM异常了。



另外由于一个应用可能会存在非常多的线程池,每个线程池都会设置一些核心线程数,要知道默认情况下核心线程是不会被回收的,即使一直处于空闲状态,该特性是由线程池的 allowCoreThreadTimeOut控制。



该参数值可通过 allowCoreThreadTimeOut(value) 函数修改



从具体实现中可以看出,当value值和当前值不同 且 value 为true时 会触发 interruptIdleWorkers()函数, 在该函数中,会对空闲Worker 调用 interrupt来中断对应线程



因此当创建线程出现OOM时,可以尝试通过调用线程池的 allowCoreThreadTimeOut 来触发 interruptIdleWorkers 实现空闲线程的回收。 具体实现代码如下:



因此我们可以在每个线程池创建后,将这些线程池用弱引用队列保存起来,当线程start 或者某个线程池execute 出现OOM异常时,通过这种方式来实现线程回收。


线程定位


线程定位 主要是指在进行问题分析时,希望直接从线程名中定位到创建该线程的业务,关于此类优化的文章网上已经介绍的比较多了,基本实现是通过ASM 修改调用函数,将当前类的类名或类名+函数名作为兜底线程名设置。这里就不详细介绍了,感兴趣的可以看 booster 中的实现



字节码修改工具


前文讲了一些优化方式,其中涉及到一个必要的操作是进行字节码修改,这些需求可以概括为如下



  • 替换类的继承关系,比如将 所有继承于 java.lang.Thread的类,替换为我们自己实现的 ProxyThread

  • 替换 new 指令的实例类型,比如将代码中 所有 new Thread(..) 的调用替换为 new ProxyThread(...)


针对这些通用的修改,没必要每次遇到类似需求时都 进行插件的单独开发,因此我将这种修改能力集成到开源库 LanceX插件中:github.com/Knight-ZXW/… ,我们可以通过以下 注解方便实现上述功能。


替换 new 指令


@Weaver
@Gr0up("threadOptimize")
public class ThreadOptimize {

@ReplaceNewInvoke(beforeType = "java.lang.Thread",
afterType = "com.knightboost.lancetx.ProxyThread")
public static void replaceNewThread(){
}

}

这里的 beforeType表示原类型,afterType 表示替换后的类型,使用该插件在项目编译后,项目中的如下源码



会被自动替换为



替换类的继承关系


@Weaver
@Gr0up("threadOptimize")
public class ThreadOptimize {

@ChangeClassExtends(
beforeExtends = "java.lang.Thread",
afterExtends = "com.knightboost.lancetx.ProxyThread"
)
public void changeExtendThread(){};



}

这里的beforeExtends表示 原继承父类,afterExtends表示修改后的继承父类,在项目编译后,如下源码



会被自动替换为



总结


本文主要介绍了有关线程的几个方面的优化



  • 主线程创建线程耗时优化

  • 线程数收敛优化

  • 线程默认虚拟空间优化

  • OOM优化


这些不同的优化手段需要根据项目的实际情况进行选择,比如主线程创建线程优化的实现方面比较简单、影响面也比较低,可以优先实施。 而线程数收敛需要涉及到字节码插桩、各种对象代理 复杂度会高一些,可以根据当前项目的实际线程数情况再考虑是否需要优化。


线程OOM问题主要出现在低端设备 或一些特定厂商的机型上,可能对于某些大厂的用户基数来说有一定的收益,如果你的App日活并没有那么大,这个优化的优先级也是较低的。

参考资料



1.某音App


2.内核线程创建流程


3.juejin.cn/post/720930… 虚拟内存优化: 线程 + 多进程优化


4.github.com/didi/booste…



作者:卓修武K
来源:juejin.cn/post/7212446354920407096
收起阅读 »

用Kotlin通杀“一切”进率换算

用Kotlin通杀“一切”进率换算之存储容量 前言 在之前的文章《用Kotlin Duration来优化时间运算》 中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(人民币汇率;长度单位m,cm;质量单位...
继续阅读 »

用Kotlin通杀“一切”进率换算之存储容量


前言


在之前的文章《用Kotlin Duration来优化时间运算》
中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(人民币汇率;长度单位m,cm;质量单位 kg,g,lb;存储容量单位 mb,gb,tb 等等)


//进率为1024
val tenMegabytes = 10 * 1024 * 1024 //10mb
val tenGigabytes = 10 * 1024 * 1024 * 1024 //10gb

这样的业务代码加入了单位换算后阅读性就变差了,能否有像Duration一样的api实现下面这样的代码呢?


fun main() {
1.kg = 2.20462262.lb; 1.m = 100.cm

val fiftyMegabytes = 50.mb
val divValue = fiftyMegabytes - 30.mb
// 20mb
val timesValue = fiftyMegabytes * 2.4
// 120mb

// 1G文件 再增加2个50mb的数据空间
val fileSpace = fiftyMegabytes * 2 + 1.gb
RandomAccessFile("fileName","rw").use {
it.setLength(fileSpace.inWholeBytes)
it.write(...)
}
}

下面我们通过分析Duration源码了解原理,并且实现存储容量单位DataSize的换算和运算。


简单拆解Duration


kotlin没有提供,要做到上面的api那么我不会啊,但是我看到Duration可以做到,那我们来看看它的原理,进行仿写就行了。



  1. 枚举DurationUnit是用来定义时间不同单位,方便换算和转换的(详情看源码或上篇文)。

  2. Duration是如何做到不同单位的数据换算的,先看看Duration的创建函数和构造函数。toDuration把当前的值通过convertDurationUnit把时间换算成nanos或millis的值,再通过shl运算用来记录单位。
    //Long创建 Duration
    public fun Long.toDuration(unit: DurationUnit): Duration {
    //最大支持的 nanos值
    val maxNsInUnit = convertDurationUnitOverflow(MAX_NANOS, DurationUnit.NANOSECONDS, unit)
    //当前值如果在最大和最小值中间 表示不会溢出
    if (this in -maxNsInUnit..maxNsInUnit) {
    //创建 rawValue 是Nanos的 Duration
    return durationOfNanos(convertDurationUnitOverflow(this, unit, DurationUnit.NANOSECONDS))
    } else {
    //创建 rawValue 是millis的 Duration
    val millis = convertDurationUnit(this, unit, DurationUnit.MILLISECONDS)
    return durationOfMillis(millis.coerceIn(-MAX_MILLIS, MAX_MILLIS))
    }
    }
    // 用 nanos
    private fun durationOfNanos(normalNanos: Long) = Duration(normalNanos shl 1)
    // 用 millis
    private fun durationOfMillis(normalMillis: Long) = Duration((normalMillis shl 1) + 1)
    //不同os平台实现,肯定是 1小时60分 1分60秒那套算法
    internal expect fun convertDurationUnit(value: Long, sourceUnit: DurationUnit, targetUnit: DurationUnit): Long


  3. Duration是一个value class用来提升性能的,通过rawValue还原当前时间换算后的nanos或millis的数据value。为何不全部都用Nanos省去了这些计算呢,根据代码看应该是考虑了Nanos的计算会溢出。用一个long值可以还原构造对象前的所有参数,这代码设计真牛逼。
    @JvmInline
    public value class Duration internal constructor(private val rawValue: Long) : Comparable<Duration> {
    //原始最小单位数据
    private val value: Long get() = rawValue shr 1
    //单位鉴别器
    private inline val unitDiscriminator: Int get() = rawValue.toInt() and 1
    private fun isInNanos() = unitDiscriminator == 0
    private fun isInMillis() = unitDiscriminator == 1
    //还原的最小单位 DurationUnit对象
    private val storageUnit get() = if (isInNanos()) DurationUnit.NANOSECONDS else DurationUnit.MILLISECONDS


  4. Duration是如何做到算术运算的,是通过操作符重载实现的。不同单位Duration,持有的数据是同一个单位的那么是可以互相运算的,我们后面会着重介绍和仿写。

  5. Duration是如何做到逻辑运算的(>,<,>=,<=),构造函数实现了接口Comparable<Duration>重写了operator fun compareTo(other: Duration): Int,返回1,-1,0



Duration主要依靠对象内部持有的rawValue: Long,由于value的单位是“相同”的,就可以实现不同单位的换算和运算。



存储容量单位换算设计




  1. 存储容量的单位一般有比特(b),字节(B),千字节(KB),兆字节(MB),千兆字节(GB),太字节(TB),拍字节(PB),艾字节(EB),泽字节(ZB),尧字节(YB)
    ,考虑到实际应用和Long的取值范围我们最大支持PB即可。


    enum class DataUnit(val shortName: String) {
    BYTES("B"),
    KILOBYTES("KB"),
    MEGABYTES("MB"),
    GIGABYTES("GB"),
    TERABYTES("TB"),
    PETABYTES("PB")
    }



  2. 对于存储容量来说最小单位我们就定为Bytes,最大支持到PB,然后可以省去对数据过大的溢出的"单位鉴别器"设计。(注意使用pb时候,>= 8192.pb就会溢出)


    @JvmInline
    value class DataSize internal constructor(private val rawBytes: Long)



  3. 参照Duration在创建和最后单位换算时候都用到了convertDurationUnit函数,接受原始单位和目标单位。另外考虑到可能出现换算溢出使用Math.multiplyExact来抛出异常,防止数据计算异常无法追溯的问题。


    /** Bytes per Kilobyte.*/
    private const val BYTES_PER_KB: Long = 1024
    /** Bytes per Megabyte.*/
    private const val BYTES_PER_MB = BYTES_PER_KB * 1024
    /** Bytes per Gigabyte.*/
    private const val BYTES_PER_GB = BYTES_PER_MB * 1024
    /** Bytes per Terabyte.*/
    private const val BYTES_PER_TB = BYTES_PER_GB * 1024
    /** Bytes per PetaByte.*/
    private const val BYTES_PER_PB = BYTES_PER_TB * 1024

    internal fun convertDataUnit(value: Long, sourceUnit: DataUnit, targetUnit: DataUnit): Long {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> Math.multiplyExact(value, BYTES_PER_KB)
    DataUnit.MEGABYTES -> Math.multiplyExact(value, BYTES_PER_MB)
    DataUnit.GIGABYTES -> Math.multiplyExact(value, BYTES_PER_GB)
    DataUnit.TERABYTES -> Math.multiplyExact(value, BYTES_PER_TB)
    DataUnit.PETABYTES -> Math.multiplyExact(value, BYTES_PER_PB)
    }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }

    internal fun convertDataUnit(value: Double, sourceUnit: DataUnit, targetUnit: DataUnit): Double {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> value * BYTES_PER_KB
    DataUnit.MEGABYTES -> value * BYTES_PER_MB
    DataUnit.GIGABYTES -> value * BYTES_PER_GB
    DataUnit.TERABYTES -> value * BYTES_PER_TB
    DataUnit.PETABYTES -> value * BYTES_PER_PB
    }
    require(!valueInBytes.isNaN()) { "DataUnit value cannot be NaN." }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }



  4. 扩展属性和构造DataSize,rawBytes是Bytes因此所有的目标单位设置为DataUnit.BYTES,而原始单位就通过调用者告诉convertDataUnit


    fun Long.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES))
    }
    fun Double.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES).roundToLong())
    }
    inline val Long.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Long.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Long.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Long.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Long.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Long.pb get() = this.toDataSize(DataUnit.PETABYTES)

    inline val Int.bytes get() = this.toLong().toDataSize(DataUnit.BYTES)
    inline val Int.kb get() = this.toLong().toDataSize(DataUnit.KILOBYTES)
    inline val Int.mb get() = this.toLong().toDataSize(DataUnit.MEGABYTES)
    inline val Int.gb get() = this.toLong().toDataSize(DataUnit.GIGABYTES)
    inline val Int.tb get() = this.toLong().toDataSize(DataUnit.TERABYTES)
    inline val Int.pb get() = this.toLong().toDataSize(DataUnit.PETABYTES)

    inline val Double.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Double.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Double.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Double.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Double.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Double.pb get() = this.toDataSize(DataUnit.PETABYTES)



  5. 换算函数设计
    Duration用toLong(DurationUnit)或者toDouble(DurationUnit)来输出指定单位的数据,inWhole系列函数是对toLong(DurationUnit) 的封装。toLong和toDouble实现就比较简单了,把convertDataUnit传入输出单位,而原始单位就是rawValue的单位DataUnit.BYTES
    toDouble需要输出更加精细的数据,例如: 512mb = 0.5gb。


    val inWholeBytes: Long
    get() = toLong(DataUnit.BYTES)
    val inWholeKilobytes: Long
    get() = toLong(DataUnit.KILOBYTES)
    val inWholeMegabytes: Long
    get() = toLong(DataUnit.MEGABYTES)
    val inWholeGigabytes: Long
    get() = toLong(DataUnit.GIGABYTES)
    val inWholeTerabytes: Long
    get() = toLong(DataUnit.TERABYTES)
    val inWholePetabytes: Long
    get() = toLong(DataUnit.PETABYTES)

    fun toDouble(unit: DataUnit): Double = convertDataUnit(bytes.toDouble(), DataUnit.BYTES, unit)
    fun toLong(unit: DataUnit): Long = convertDataUnit(bytes, DataUnit.BYTES, unit)



操作符设计


在Kotlin 中可以为类型提供预定义的一组操作符的自定义实现,被称为操作符重载。这些操作符具有预定义的符号表示(如 + 或
*)与优先级。为了实现这样的操作符,需要为相应的类型提供一个指定名称的成员函数或扩展函数。这个类型会成为二元操作符左侧的类型及一元操作符的参数类型。


如果函数不存在或不明确,则导致编译错误(编译器会提示报错)。下面为常见操作符对照表:


操作符函数名说明
+aa.unaryPlus()一元操作 取正
-aa.unaryMinus()一元操作 取负
!aa.not()一元操作 取反
a + ba.plus(b)二元操作 加
a - ba.minus(b)二元操作 减
a * ba.times(b)二元操作 乘
a / ba.div(b)二元操作 除

算术运算支持



  1. 这里用算术运算符+实现来举例:假如DataSize对象需要重载操作符+
    val a = DataSize(); val c: DataSize = a + b


  2. 需要定义扩展函数1或者添加成员函数2
    1. operator fun DataSize.plus(other: T): DataSize {...}
    2. class DataSize { operator fun plus(other: T): DataSize {...} }


  3. 函数中的参数other: T表示b的对象类型,例如
    // val a: DataSize; val b: DataSize; a + DataSize()
    operator fun DataSize.plus(other: DataSize): DataSize {...}
    // val a: DataSize; val b: Int; a + 1
    operator fun DataSize.plus(other: Int): DataSize {...}


  4. 为了阅读性,Duration不会和同类型的对象乘除法运算,而使用了Int或Double,因此重载运算符用了operator fun times(scale: Int): Duration

  5. 那么在DataSize中我们也重载(+,-,*,/),并且(*,/)重载的参数只支持Int和Double即可
    operator fun unaryMinus(): DataSize {
    return DataSize(-this.bytes)
    }
    operator fun plus(other: DataSize): DataSize {
    return DataSize(Math.addExact(this.bytes, other.bytes))
    }

    operator fun minus(other: DataSize): DataSize {
    return this + (-other) // a - b = a + (-b)
    }

    operator fun times(scale: Int): DataSize {
    return DataSize(Math.multiplyExact(this.bytes, scale.toLong()))
    }

    operator fun div(scale: Int): DataSize {
    return DataSize(this.bytes / scale)
    }

    operator fun times(scale: Double): DataSize {
    return DataSize((this.bytes * scale).roundToLong())
    }

    operator fun div(scale: Double): DataSize {
    return DataSize((this.bytes / scale).roundToLong())
    }

    上面的操作符重载中minus(),我们使用了 plus()unaryMinus()重载组合a-b = a+(-b),这样我们可以多一个-DataSize的操作符


逻辑运算支持




  • (>,<,>=,<=)让DataSize构造函数实现了接口Comparable<DataSize>重写了operator fun compareTo(other: DataSize): Int,返回rawBytes对比值即可。




  • (==,!=)通过equals(other)函数实现的,value class默认为rawBytes的对比,可以通过java字节码看到。kotlin 1.9之前不支持重写value classs的equals和hashCode


    value class DataSize internal constructor(private val bytes: Long) : Comparable<DataSize> {
    override fun compareTo(other: DataSize): Int {
    return this.bytes.compareTo(other.bytes)
    }
    //示例
    600.mb > 0.5.gb //true
    512.mb == 0.5.gb




操作符重载的目的是为了提升阅读性,并不是所有对象为了炫酷都可以用操作符重载,滥用反而会增加代码的阅读难度。例如给DataSize添加*操作符,5mb * 2mb 就让人头大。



获取字符串形式


为了方便打印和UI展示,一般我们需要重写toSting。Duration的toSting不需要指定输出单位,可以详细的输出当前对象的字符串格式(1h 0m 45.677s)算法比较复杂。我不太会,就简单实现指定输出单位的toString(DataUnit)


 override fun toString(): String = String.format("%dB", rawBytes)

fun toString(unit: DataUnit, decimals: Int = 2): String {
require(decimals >= 0) { "decimals must be not negative, but was $decimals" }
val number = toDouble(unit)
if (number.isInfinite()) return number.toString()
val newDecimals = decimals.coerceAtMost(12)
return DecimalFormat("0").run {
if (newDecimals > 0) minimumFractionDigits = newDecimals
roundingMode = RoundingMode.HALF_UP
format(number) + unit.shortName
}
}

单元测试


功能都写好了需要验证期望的结果和实现的功能是否一直,那么这个时候就用单元测试最好来个100%覆盖。


class ExampleUnitTest {
@Test
fun data_size() {
val dataSize = 512.mb

println("format bytes:$dataSize")
// format bytes:536870912B
println("format kb:${dataSize.toString(DataUnit.KILOBYTES)}")
// format kb:524288.00KB
println("format gb:${dataSize.toString(DataUnit.GIGABYTES)}")
// format gb:0.50GB
// 单位换算
assertEquals(536870912, dataSize.inWholeBytes)
assertEquals(524288, dataSize.inWholeKilobytes)
assertEquals(512, dataSize.inWholeMegabytes)
assertEquals(0, dataSize.inWholeGigabytes)
assertEquals(0, dataSize.inWholeTerabytes)
assertEquals(0, dataSize.inWholePetabytes)
}

@Test
fun data_size_operator() {
val dataSize1 = 512.mb
val dataSize2 = 3.gb

val unaryMinusValue = -dataSize1 //取负数
println("unaryMinusValue :${unaryMinusValue.toString(DataUnit.MEGABYTES)}")
// unaryMinusValue :-512.00MB

val plusValue = dataSize1 + dataSize2 //+
println("plus :${plusValue.toString(DataUnit.GIGABYTES)}")
// plus :3.50GB

val minusValue = dataSize1 - dataSize2 // -
println("minus :${minusValue.toString(DataUnit.GIGABYTES)}")
// minus :-2.50GB

val timesValue = dataSize1 * 2 //乘法
println("times :${timesValue.toString(DataUnit.GIGABYTES)}")
// times :1.00GB

val divValue = dataSize2 / 2 //除法
println("div :${divValue.toString(DataUnit.GIGABYTES)}")
// div :1.50GB
}

@Test(expected = ArithmeticException::class)
fun data_size_overflow() {
8191.pb
8192.pb //溢出了不支持,如果要支持参考"单位鉴别器"设计
}

@Test
fun data_size_compare() {
assertTrue(600.mb > 0.5.gb)
assertTrue(512.mb == 0.5.gb)
}
}

总结


通过学习Kotlin Duration的源码,举一反三应用到储存容量单位转换和运算中。Duration中的拆解计算api,还有toSting算法实现就留给大家学习吧。当然了你也可以实现和Duration一样更加精细的"单位鉴别器"设计,支持bit、ZB、BB等大单位。


另外类似的进率场景也可以实现,用Kotlin通杀“一切进率换算”。比如Degrees角度计算 -90.0.degrees == 270.0.degrees;质量计算kg和磅,两等等1.kg == 2.20462262.lb;人民币汇率 (动态实现算法)8.dollar == 1.rmb 🐶


github 代码: github.com/forJrking/K…


操作符重载文档: book.kotlincn.net/text/operat…


作者:forJrking
来源:juejin.cn/post/7301145359852765218
收起阅读 »

MQTT客户端学习路线总结

本篇仅仅是记录一下MQTT学习的过程和感想,文字偏多 前言 总结一下MQTT协议的学习过程, 大概分为9步。 在IoT最热门时,有过一些了解,仅限名词解释。这次在为工厂装修设计时,涉及到了一些智能设备,也因此近距离接触到了MQTT协议相关的系统。虽然直接采...
继续阅读 »

本篇仅仅是记录一下MQTT学习的过程和感想,文字偏多



前言


总结一下MQTT协议的学习过程, 大概分为9步。


MQTT学习过程.jpg


在IoT最热门时,有过一些了解,仅限名词解释。这次在为工厂装修设计时,涉及到了一些智能设备,也因此近距离接触到了MQTT协议相关的系统。虽然直接采购的是成品(包含施工方案),但出于好奇和喜欢动手的本能,我想学习一下MQTT通信应用协议


使用


直接体验采购的智能设备,也算是使用,但总感觉少了点什么-感觉没有真正体验到MQTT协议通信。基于此种想法,翻阅MQTT环境搭建的指导文章,开始在自己的电脑上捣鼓安装MQTT客户端软件和MQTT服务器软件,一番折腾后,成功安装了MQTTXmosquitto


在MQTTX客户端上,看到可以成功的接收消息和发送消息,瞬间有种傻瓜式的成就感(我会用MQTT协议啦)。


体验完MQTTX,按照mosquitto官方指导,竟然发现它既可用作服务器同时还可用做客户端,在mac终端中急切的敲着发送主题的命令,在MQTTX上看到收到的消息,就一个感觉:呗爽。


对于一个新手,MQTTX和mosquitto的成就感促使我想继续阅读MQTT相关的文章,也因此从MQTT官网找到了Steve's Internet Guide博客


体验了MQTT软件和阅读了Steve's Internet Guide后,对运行一个客户端源码工程十分渴望,如此优秀的博客,配上程序运行时的日志,简直是学习MQTT的“下饭菜”。决定了就开干,从MQTT官网选型合适的MQTT协议实现库,最终我选定了Elipse paho


实践


Elipse paho共包含了17种版本,涉及多种语言(C语言,python, javascript, C++, golang, ruby, rust), 我实践学习用的是paho.mqtt.java版本。


用IntelliJ IDEA(社区版)创建一个属于自己的命令式应用MQTTHarvey, 将“org.eclipse.paho.mqttv5.client” 源码引入自己的应用。然后创建自己实践用入口代码(包括场景模拟和日志打印)。


最佳体验的方式:上手直接敲代码,调用核心功能API,看效果。如果API不熟,记住功能名称,疯狂的在掘金中搜索相关的介绍文章,比如:MQTT如何发送主题, MQTT如何订阅等等。


在首次成功运行已集成好源码的工程后,针对PUBLISH,SUBCRIBE需要先实践一遍(当然,最初我对实践这两个消息的称呼为:发送消息,接收消息).


消息的发送和接收源码在哪里?带着这个疑问,“胡乱一通”断点,终于找到了地方,接收消息的文件是MqttInputStream.java, 发送消息的文件是MqttOutputStream.java。


协议流到底是什么样子?能不能输出这些字节流,以观其全貌,知其结构。


协议初探


既然是协议,那必然有数据格式规范,编码后的数据按照字节流的方式进行传输,那么将字节流按照字节一个一个打印日志输出出来,就是协议的样子。我应该最先了解哪个协议样子呢?依据基础常识,MQTT客户端开始运行时,与服务端建立连接肯定是第一次联网操作,既然是这个样子,MQTT有没有关于联机的协议呢?经过一顿搜索,CONNECT 是关于客户端连接服务器的协议。那如何将其打印出来呢?因为都是字节流,又不了解协议规范,想要打印出来真的比较难。


正向研究协议在刚开始阶段,硬杠还是比较浪费时间的,甚至会打击继续学习的信心。后来我才用了一个比较讨巧的方式, 写一段只包含连接服务器的代码,就可以解决难以仅仅打印CONNECT协议的问题。


在二进制流打印完成后,在IDE控制台看到的日志,其实都是一个字节一个字节的字符串信息,很难以看懂。这个时候就需要拿出MQTT规范文档找到CONNECT协议的定义,然后手动尝试去解析每个字节,直到可以把字节都能和规范对上号,才可以证明对这个协议稍微搞懂了。


在手动完成CONNECT协议后,我就迫不及待的想看看消息发送协议,后来想了一下,没必要那么急,毕竟发送消息协议是如此的重要,肯定是比较复杂的。调整一下探究方向,一个新手,想要学习协议分析,从简单的协议入手,比如:断开连接,心跳。因此,我第二个分析的协议就是DISCONNECT,带着好奇的激情,最终完成了这个协议分析,那一天晚上睡觉时,感觉都是无比的开心。


解码


其实软件的职责就是能自动解决规范化的一些流程问题,数据格式问题。在协议初探时,还停留在手动分析字节流阶段,毕竟MQTT客户端如果应用在真实场景,自动解码字节流这样的功能,肯定是必备的。


既然在研究MQTT协议,那就需要拿出点诚意,自己写程序来解码字节流,然后将其拆解为规范中可描述的文字。


依然从简单的协议分析,然后再去分析复杂的。通过MQTT规范文档了解到,CONNECT,PING,DISCONNECT都是相对比较简单的,也容易写测试代码来完成解码实验场景。


在真正通过代码来解码CONNECT协议时,才发现固定头,可变头真的在用代码一步一步解码时,非常容易出错,因为是新手,再加上急于求成(毕竟已经会手动分析了),程序要么执行到一半就发生异常,要么在测试代码中稍微调整一下参数,程序就会崩溃。冷静下来后,发现还是要按照规范文档,一个属性一个属性往下研究,不能急躁。并且在这个过程中,发现自己对待MQTT规范不够重视,连数据类型都直接给忽略了,比如规范中的 “1.5 Data representation” 内容。因为没有重视它,导致在按照规范实现解码时,时常囫囵吞枣,自己认为是代表的是什么意思,就赶紧敲代码实现。


在刚开始对协议解码时,因为懒,所以只针对数据包的头部做了解码,完成了控制包类型1到14(控制包类型:“2.1.2 MQTT Control Packet type”)。完成之后,回看代码发现重复代码很多,分析完重复部分后,才发现像Reason Code,UTF-8 Encoded, Variable Byte Integer这些都是具有全局性的,即:可以把他们的解码分别做成公共部分。为了证实自己的这一点理解,赶紧翻看客户端源码是不是这样的,果不其然,理解正确。


工程分析


因为已经完成了一部分的解码工作,并且对MQTT规范阅读也已经上道,所以就想歇一歇,安静的学习一下客户端源码,它到底是如何实现发送接收消息的,它的代码是如何实现了每一条MQTT规范的。带着这些疑问,先确定程序执行每个控制包的方法调用流程,然后确定运行时的线程数,最终再分析代码之间的依赖关系。


代码跟踪是枯燥的,看别人将协议实现的如此漂亮,深深的受了打击。


每天偶尔看看源码,想象是自己在维护它,熟练的记住每个API,让自己的信心慢慢恢复。


术语理解


有了阅读MQTT规范的技能,自己解码的能力和分析源码之后,有种放空茫然的感觉。MQTT到底是什么,我如何描述它,它在布道时,是如何宣传的,等等?


可能要解决上述疑惑,需要找MQTT官方资料认真学习一下,然后再看看中文是如何教授的。


因此,我从MQTT官网找到HIVEMQ发布的MQTT基础文章,整个系列有十部分内容,然后再逐字逐句翻译,通过翻译,让自己产生疑问,然后再带着疑问去阅读MQTT规范,如果还是无法理解,再通过代码做实验。总之,硬着头皮也要将MQTT规范中的术语或者别人对MQTT的描述理解清楚。


场景模拟


在完成HIVEMQ基础文章的翻译之后,算得上对MQTT有点感觉了,也因此想要将自己的实践测试代码,能不能按照使用场景,分别实现一遍。



场景



  • 连接 -> 断开 -> 重连

  • 连接 -> 订阅 -> 解除订阅

  • 连接 -> 心跳

  • 连接 -> 恢复会话

  • 等等



为什么做这样的事情,我是基于2个原因:1. 我对代码库API及参数使用不熟 2. 提前模拟这些场景,对实战中发生的故障分析,肯定有指导作用


MQTT协议实现


通过一系列的学习和实践,如果真的已经搞懂了MQTT,那么我应该自己可以实现一个简化版的MQTT客户端


比如:实现最简化的功能,连接broker服务器


graph TD
建立Socket --> 发送CONNECT协议 --> 解析CONNACK

当然,自研一个MQTT客户端,从个人来讲,确实对技术能力提升有很大帮助,从公司来讲,那就是Money(比如:杭州映云科技有限公司)。


扩展


短时间内是否真的可以搞懂MQTT?难。协议规范纯理论学习是可以慢慢搞的十分清楚的,但MQTT最终是为了解决生活中实际的通信问题的,那就意味网络原因,数据安全也好,都可能让一个开发人员耗费大量的时间去排查定位问题。基于此种考虑,想要提高技能,增加增经验,可能需要建立问题库,及查看其他人或者公司在使用MQTT过程中的问题列表,并且尝试思考是否能给出解决方案。


作者:harvey_fly
来源:juejin.cn/post/7278953365224734774
收起阅读 »

Android 属性系统入门

这是一个介绍 Android 属性系统的系列文章: Android 属性系统入门(本文) 属性文件生成过程分析 如何添加系统属性 属性与 Selinux 属性系统整体框架与启动过程分析 属性读写过程源码分析 本文基于 AOSP android-10.0.0...
继续阅读 »

这是一个介绍 Android 属性系统的系列文章:



  • Android 属性系统入门(本文)

  • 属性文件生成过程分析

  • 如何添加系统属性

  • 属性与 Selinux

  • 属性系统整体框架与启动过程分析

  • 属性读写过程源码分析


本文基于 AOSP android-10.0.0_r41 版本讲解


在 Android 系统中,为统一管理系统的属性,设计了一个统一的属性系统,每个属性都是一个 key-value 对。
我们可以通过 shell 命令,Native 函数接口,Java 函数接口的方式来读写这些 key-vaule 对。


属性在哪里?


init 进程在启动会去加载后缀为 .prop 的属性文件, 将属性文件中的属性加载到共享内存中, 这样系统就有了默认的一些属性。


属性文件都在哪里呢?


属性文件的后缀绝大部分都是 prop,我们可以在 Android 模拟器的 shell 环境下搜索:


find . -name "*.prop"

/default.prop
/data/local.prop
/system/build.prop
/system/product/build.prop
/vendor/build.prop
/vendor/odm/etc/build.prop
/vendor/default.prop

我们看看 /default.prop 属性文件的内容:


cat /default.prop

#
# ADDITIONAL_DEFAULT_PROPERTIES
#
ro.actionable_compatible_property.enabled=true
ro.postinstall.fstab.prefix=/system
ro.secure=0
ro.allow.mock.location=1
ro.debuggable=1
debug.atrace.tags.enableflags=0
dalvik.vm.image-dex2oat-Xms=64m
dalvik.vm.image-dex2oat-Xmx=64m
dalvik.vm.dex2oat-Xms=64m
dalvik.vm.dex2oat-Xmx=512m
dalvik.vm.usejit=true
dalvik.vm.usejitprofiles=true
dalvik.vm.dexopt.secondary=true
dalvik.vm.appimageformat=lz4
ro.dalvik.vm.native.bridge=0
pm.dexopt.first-boot=extract
pm.dexopt.boot=extract
pm.dexopt.install=speed-profile
pm.dexopt.bg-dexopt=speed-profile
pm.dexopt.ab-ota=speed-profile
pm.dexopt.inactive=verify
pm.dexopt.shared=speed
dalvik.vm.dex2oat-resolve-startup-strings=true
dalvik.vm.dex2oat-max-image-block-size=524288
dalvik.vm.minidebuginfo=true
dalvik.vm.dex2oat-minidebuginfo=true
ro.iorapd.enable=false
tombstoned.max_tombstone_count=50
persist.traced.enable=1
ro.com.google.locationfeatures=1
ro.setupwizard.mode=DISABLED
persist.sys.usb.config=adb

可以看出属性确实是一些 key-value 对。


init 进程会调用 property_load_boot_defaults 函数来加载属性文件:


void property_load_boot_defaults(bool load_debug_prop) {
// TODO(b/117892318): merge prop.default and build.prop files int0 one
// We read the properties and their values int0 a map, in order to always allow properties
// loaded in the later property files to override the properties in loaded in the earlier
// property files, regardless of if they are "ro." properties or not.
std::map<std::string, std::string> properties;
if (!load_properties_from_file("/system/etc/prop.default", nullptr, &properties)) {
// Try recovery path
if (!load_properties_from_file("/prop.default", nullptr, &properties)) {
// Try legacy path
load_properties_from_file("/default.prop", nullptr, &properties);
}
}
load_properties_from_file("/system/build.prop", nullptr, &properties);
load_properties_from_file("/vendor/default.prop", nullptr, &properties);
load_properties_from_file("/vendor/build.prop", nullptr, &properties);
if (SelinuxGetVendorAndroidVersion() >= __ANDROID_API_Q__) {
load_properties_from_file("/odm/etc/build.prop", nullptr, &properties);
} else {
load_properties_from_file("/odm/default.prop", nullptr, &properties);
load_properties_from_file("/odm/build.prop", nullptr, &properties);
}
load_properties_from_file("/product/build.prop", nullptr, &properties);
load_properties_from_file("/product_services/build.prop", nullptr, &properties);
load_properties_from_file("/factory/factory.prop", "ro.*", &properties);

if (load_debug_prop) {
LOG(INFO) << "Loading " << kDebugRamdiskProp;
load_properties_from_file(kDebugRamdiskProp, nullptr, &properties);
}

for (const auto& [name, value] : properties) {
std::string error;
if (PropertySet(name, value, &error) != PROP_SUCCESS) {
LOG(ERROR) << "Could not set '" << name << "' to '" << value
<< "' while loading .prop files" << error;
}
}

property_initialize_ro_product_props();
property_derive_build_fingerprint();

update_sys_usb_config();
}

从源码中我们也可以看到 init 进程加载了哪些属性文件以及加载的顺序。


属性长什么样?


每一个属性是一个 key-value 对:


ro.actionable_compatible_property.enabled=true
ro.postinstall.fstab.prefix=/system
ro.secure=0
ro.allow.mock.location=1
ro.debuggable=1
debug.atrace.tags.enableflags=0
dalvik.vm.image-dex2oat-Xms=64m
dalvik.vm.image-dex2oat-Xmx=64m

等号左边是属性的名字,等号右边是属性的值


属性的分类:



  • 一般属性:普通的 key-value 对,没有其他功能,系统启动后,如果修改了某个属性值(仅修改了内存中的值,未写入到文件),再重启系统,修改的值不会被保存下来,读取到的仍是修改前的值

  • 特殊属性

    • 属性名称以 ro 开头,那么这个属性被视为只读属性。一旦设置,属性值不能改变。

    • net 开头的属性,顾名思义,就是与网络相关的属性,net 属性中有一个特殊的属性:net.change,它记录了每一次最新设置和更新的 net 属性,也就是每次设置和更新 net,属性时则会自动的更新 net.change 属性,net.change 属性的 value 就是这个被设置或者更新的 net 属性的 name。例如我们更新了属性 net.bt.name 的值,由于 net 有属性发生了变化,那么属性服务就会自动更新 net.change,将其值设置为 net.bt.name

    • persist 为开头的属性值,当在系统中通过 setprop 命令设置这个属性时,就会在 /data/property/ 目录下会保存一个副本。这样在系统重启后,按照加载流程这些 persist 属性的值就不会消失了。

    • 属性 ctrl.startctrl.stop 是用来启动和停止服务。这里的服务是指定义在 rc 后缀文件中的服务。当我们向 ctrl.start 属性写入一个值时,属性服务将使用该属性值作为服务名找到该服务,启动该服务。这项服务的启动结果将会放入 init.svc.<服务名> 属性中,可以通过查询这个属性值,以确定服务是否已经启动。




如何读写属性:


命令行:


getprop "wlan.driver.status"
setprop "wlan.driver.status" "timeout"

Native 代码:


char buf[20]="qqqqqq";
char tempbuf[PROPERTY_VALUE_MAX];
property_set("type_value",buf);
property_get("type_value",tempbuf,"0");

Java 代码:


String navBarOverride = SystemProperties.get("qemu.hw.mainkeys");
SystemProperties.set("service.bootanim.exit", "0");

属性的作用


常见的属性文件的作用如下:



参考资料



作者:阿豪讲Framework
来源:juejin.cn/post/7298645450555326464
收起阅读 »

底部弹出菜单原来这么简单

底部弹出菜单是什么 底部弹出菜单,即从app界面底部弹出的一个菜单列表,这种UI形式被众多app所采用,是一种主流的布局方式。 思路分析 我们先分析一下,这样一种UI应该由哪些布局组成?首先在原界面上以一小块区域显示界面的这种形式,很明显就是对话框Dial...
继续阅读 »

底部弹出菜单是什么


底部弹出菜单,即从app界面底部弹出的一个菜单列表,这种UI形式被众多app所采用,是一种主流的布局方式。


截屏2023-09-28 14.36.51.png


截屏2023-09-28 14.37.29.png


思路分析


我们先分析一下,这样一种UI应该由哪些布局组成?首先在原界面上以一小块区域显示界面的这种形式,很明显就是对话框Dialog做的事情吧!最底部是一个取消菜单,上面的功能菜单可以是一个,也可以是两个、三个甚至更多。所以,我们可以使用RecyclerView实现。需要注意一点的是,最上面那个菜单的样式稍微有点不一样,因为它上面是圆滑的,有圆角,这样的界面显示更加和谐。我们主要考虑的就是弹出对话框的动画样式,另外注意一点就是可以多支持几个语种,让框架更加专业,这里只需要翻译“取消”文字。


开始看代码


package dora.widget

import android.app.Activity
import android.app.Dialog
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter.base.BaseQuickAdapter
import com.chad.library.adapter.base.listener.OnItemChildClickListener
import dora.widget.bean.BottomMenu
import dora.widget.bottomdialog.R

class DoraBottomMenuDialog : View.OnClickListener, OnItemChildClickListener {

private var bottomDialog: Dialog? = null
private var listener: OnMenuClickListener? = null

interface OnMenuClickListener {
fun onMenuClick(position: Int, menu: String)
}

fun setOnMenuClickListener(listener: OnMenuClickListener) : DoraBottomMenuDialog {
this.listener = listener
return this
}

fun show(activity: Activity, menus: Array<String>): DoraBottomMenuDialog {
if (bottomDialog == null && !activity.isFinishing) {
bottomDialog = Dialog(activity, R.style.DoraView_AlertDialog)
val contentView =
LayoutInflater.from(activity).inflate(R.layout.dview_dialog_content, null)
initView(contentView, menus)
bottomDialog!!.setContentView(contentView)
bottomDialog!!.setCanceledOnTouchOutside(true)
bottomDialog!!.setCancelable(true)
bottomDialog!!.window!!.setGravity(Gravity.BOTTOM)
bottomDialog!!.window!!.setWindowAnimations(R.style.DoraView_BottomDialog_Animation)
bottomDialog!!.show()
val window = bottomDialog!!.window
window!!.decorView.setPadding(0, 0, 0, 0)
val lp = window.attributes
lp.width = WindowManager.LayoutParams.MATCH_PARENT
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
window.attributes = lp
} else {
bottomDialog!!.show()
}
return this
}

private fun initView(contentView: View, menus: Array<String>) {
val recyclerView = contentView.findViewById<RecyclerView>(R.id.dview_recycler_view)
val adapter = MenuAdapter()
val list = mutableListOf<BottomMenu>()
menus.forEachIndexed { index, s ->
when (index) {
0 -> {
list.add(BottomMenu(s, BottomMenu.TOP_MENU))
}
else -> {
list.add(BottomMenu(s, BottomMenu.NORMAL_MENU))
}
}
}
adapter.setList(list)
recyclerView.adapter = adapter
val decoration = DividerItemDecoration(contentView.context, DividerItemDecoration.VERTICAL)
recyclerView.addItemDecoration(decoration)
adapter.addChildClickViewIds(R.id.tv_menu)
adapter.setOnItemChildClickListener(this)
val tvCancel = contentView.findViewById<TextView>(R.id.tv_cancel)
tvCancel.setOnClickListener(this)
}

private fun dismiss() {
bottomDialog?.dismiss()
bottomDialog = null
}

override fun onClick(v: View) {
when (v.id) {
R.id.tv_cancel -> dismiss()
}
}

override fun onItemChildClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) {
listener?.onMenuClick(position, adapter.getItem(position) as String)
dismiss()
}
}

类的结构不仅可以继承,还可以使用聚合和组合的方式,我们这里就不直接继承Dialog了,使用一种更接近代理的一种方式。条条大路通罗马,能抓到老鼠的就是好猫。这里的设计是通过调用show方法,传入一个菜单列表的数组来显示菜单,调用dismiss方法来关闭菜单。最后添加一个菜单点击的事件,把点击item的内容和位置暴露给调用方。


package dora.widget

import com.chad.library.adapter.base.BaseMultiItemQuickAdapter
import com.chad.library.adapter.base.viewholder.BaseViewHolder
import dora.widget.bean.BottomMenu
import dora.widget.bottomdialog.R

class MenuAdapter : BaseMultiItemQuickAdapter<BottomMenu, BaseViewHolder>() {

init {
addItemType(BottomMenu.NORMAL_MENU, R.layout.dview_item_menu)
addItemType(BottomMenu.TOP_MENU, R.layout.dview_item_menu_top)
}

override fun convert(holder: BaseViewHolder, item: BottomMenu) {
holder.setText(R.id.tv_menu, item.menu)
}
}

多类型的列表布局我们采用BRVAH,


implementation("io.github.cymchad:BaseRecyclerViewAdapterHelper:3.0.10")

来区分有圆角和没圆角的item条目。


<?xml version="1.0" encoding="utf-8"?>

<resources>
<style name="DoraView.AlertDialog" parent="@android:style/Theme.Dialog">
<!-- 是否启用标题栏 -->
<item name="android:windowIsFloating">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>

<!-- 是否使用背景半透明 -->
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:background">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
</style>

<style name="DoraView.BottomDialog.Animation" parent="Animation.AppCompat.Dialog">
<item name="android:windowEnterAnimation">@anim/translate_dialog_in</item>
<item name="android:windowExitAnimation">@anim/translate_dialog_out</item>
</style>
</resources>

以上是对话框的样式。我们再来看一下进入和退出对话框的动画。


translate_dialog_in.xml


<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:duration="300"
android:fromXDelta="0"
android:fromYDelta="100%"
android:toXDelta="0"
android:toYDelta="0">

</translate>

translate_dialog_out.xml


<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:duration="300"
android:fromXDelta="0"
android:fromYDelta="0"
android:toXDelta="0"
android:toYDelta="100%">

</translate>

最后给你们证明一下我是做了语言国际化的。
截屏2023-09-28 15.08.20.png


使用方式


// 打开底部弹窗
val dialog = DoraBottomMenuDialog()
dialog.setOnMenuClickListener(object : DoraBottomMenuDialog.OnMenuClickListener {
override fun onMenuClick(position: Int, menu: String) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(url)
startActivity(intent)
}
})
dialog.show(this, arrayOf("外部浏览器打开"))

开源项目


github.com/dora4/dview…


作者:dora
来源:juejin.cn/post/7283516197487214611
收起阅读 »

从Kotlin中return@forEach了个寂寞

今天在Review(copy)同事代码的时候,发现了一个问题,想到很久之前,自己也遇到过这个问题,那么就来看下吧。首先,我们抽取最小复现代码。 (1..7).forEach { if (it == 3) { return@forEach...
继续阅读 »

今天在Review(copy)同事代码的时候,发现了一个问题,想到很久之前,自己也遇到过这个问题,那么就来看下吧。首先,我们抽取最小复现代码。


(1..7).forEach {
if (it == 3) {
return@forEach
}
Log.d("xys", "Num: $it")
}

�很简单的代码,我相信很多人都这样写过,实际上就是遍历的过程中,满足条件后就退出遍历,那么上面的代码,能实现这样的需求吗?我们来看下执行结果。


Num: 1
Num: 2
Num: 4
Num: 5
Num: 6
Num: 7

很遗憾,即使等于3之后就return了,但是然并卵,遍历依然继续执行了。相信很多写Kotlin的开发者都遇到过这个问题,其原因,还是在于语法的思维定势,我们在Kotlin的文档上,可以找到非常明确的解释。
kotlinlang.org/docs/return…


我们先来看下Kotlin中forEach的源码。


/**
* Performs the given [action] on each element.
*/

@kotlin.internal.HidesMembers
public inline fun Iterable.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

�我们来提取下关键信息:



  • 内联函数

  • 高阶函数


发现了吗,由于高阶函数的存在,当你在高阶函数的闭包内「return」时,是结束的整个函数,当你使用「return@forEach�」时,是结束当前的闭包,所以,如果你像这样写:


(1..7).forEach {
if (it == 3) {
return
}
Log.d("xys", "Num: $it")
}

那么等于3之后,整个函数就被return了,那么如果你像文章开头这样写,那么等效于continue,因为你结束了当前的闭包,而这个闭包只是其中的一次遍历过程。那么我们要如何实现我们最初的需求呢?看到这样,答案其实已经呼之欲出了,那就是要return整个遍历的闭包。所以,官方也给出了解决方案,那就是外面套一层闭包:


run loop@{
(1..7).forEach {
if (it == 3) {
return@loop
}
Log.d("xys", "Num: $it")
}
}

写起来确实是麻烦一点,但这却是必不可少的过程,是引入闭包所带来的一点副作用。



当然这里不仅限于run,任何闭包都是可以的。


作者:xuyisheng
来源:juejin.cn/post/7243819009866235964
收起阅读 »

鸿蒙 AkrUI 零基础教程第一集

前言 各位同学有段时间没有见面 因为一直很忙所以就你没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章 线性布局(Row/Column) 线性布局(L...
继续阅读 »

前言


各位同学有段时间没有见面 因为一直很忙所以就你没有去更新博客。最近有在学习这个鸿蒙的ark ui开发 因为鸿蒙不是发布了一个鸿蒙next的测试版本 明年会启动纯血鸿蒙应用 所以我就想提前给大家写一些博客文章


线性布局(Row/Column)


线性布局(LinearLayout)是开发中最常用的布局,通过线性容器RowColumn构建。线性布局是其他布局的基础,其子元素在线性方向上(水平方向和垂直方向)依次排列。线性布局的排列方向由所选容器组件决定,Column容器内子元素按照垂直方向排列,Row容器内子元素按照水平方向排列。根据不同的排列方向,开发者可选择使用Row或Column容器创建线性布局。这个比较像flutter里面线性布局 学过flutter的就比较容易理解


横向线性布局


image.png


纵向线性布局


image.png


基本概念



  • 布局容器:具有布局能力的容器组件,可以承载其他元素作为其子元素,布局容器会对其子元素进行尺寸计算和布局排列。

  • 布局子元素:布局容器内部的元素。

  • 主轴:线性布局容器在布局方向上的轴线,子元素默认沿主轴排列。Row容器主轴为横向,Column容器主轴为纵向。

  • 交叉轴:垂直于主轴方向的轴线。Row容器交叉轴为纵向,Column容器交叉轴为横向。

  • 间距:布局子元素的间距。


具体代码实现


横向线性布局


@Entry
@Component
struct Index {
build() {
Row() {
Column({ space: 20 }) {
Row().width('90%').height(50).backgroundColor(0xFF0000)
Row().width('90%').height(50).backgroundColor(0xFF0000)
Row().width('90%').height(50).backgroundColor(0xFF0000)
}.width('100%')
}
.height('100%')
}
}



image.png


纵向线性布局


@Entry
@Component
struct Index {
build() {
Row({ space: 35 }) {
Text('space: 35').fontSize(15).fontColor(Color.Gray)
Row().width('10%').height(150).backgroundColor(0xFF0000)
Row().width('10%').height(150).backgroundColor(0xFF0000)
Row().width('10%').height(150).backgroundColor(0xFF0000)
}.width('90%')
}
.height('100%')
}
}

image.png


布局子元素在交叉轴上的对齐方式


在布局容器内,可以通过alignItems属性设置子元素在交叉轴(排列方向的垂直方向)上的对齐方式。且在各类尺寸屏幕中,表现一致。其中,交叉轴为垂直方向时,取值为VerticalAlign类型,水平方向取值为HorizontalAlign
alignSelf属性用于控制单个子元素在容器交叉轴上的对齐方式,其优先级高于alignItems属性,如果设置了alignSelf属性,则在单个子元素上会覆盖alignItems属性。



  • HorizontalAlign.Start:子元素在水平方向左对齐


@Entry
@Component
struct Index {
build() {
Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').alignItems(HorizontalAlign.Start).backgroundColor('rgb(242,242,242)')
}
}

image.png
HorizontalAlign.Center:子元素在水平方向居中对齐



@Entry
@Component
struct Index {
build() {
Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').alignItems(HorizontalAlign.Center).backgroundColor('rgb(242,242,242)')
}
}

image.png



  • HorizontalAlign.End:子元素在水平方向右对齐



@Entry
@Component
struct Index {
build() {
Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').alignItems(HorizontalAlign.End).backgroundColor('rgb(242,242,242)')
}
}

image.png


Row容器内子元素在垂直方向上的排列



  • VerticalAlign.Top:子元素在垂直方向顶部对齐。


@Entry
@Component
struct Index {
build() {
// VerticalAlign.Center:子元素在垂直方向居中对齐
Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).alignItems(VerticalAlign.Top).backgroundColor('rgb(242,242,242)')

}
}

image.png



  • VerticalAlign.Center:子元素在垂直方向居中对齐



@Entry
@Component
struct Index {
build() {
// VerticalAlign.Bottom:子元素在垂直方向底部对齐。

Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).alignItems(VerticalAlign.Center).backgroundColor('rgb(242,242,242)')


}
}

image.png



  • VerticalAlign.Bottom:子元素在垂直方向底部对齐


@Entry
@Component
struct Index {
build() {
// VerticalAlign.Bottom:子元素在垂直方向底部对齐
Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).alignItems(VerticalAlign.Bottom).backgroundColor('rgb(242,242,242)')
}
}

image.png


布局子元素在主轴上的排列方式


在布局容器内,可以通过justifyContent属性设置子元素在容器主轴上的排列方式。可以从主轴起始位置开始排布,也可以从主轴结束位置开始排布,或者均匀分割主轴的空间


Column容器内子元素在主轴上的排列


justifyContent(FlexAlign.Start):元素在主轴方向首端对齐,第一个元素与行首对齐,同时后续的元素与前一个对齐



@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.Start):元素在主轴方向首端对齐,第一个元素与行首对齐,同时后续的元素与前一个对齐
Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').height(300).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.Start)
}
}

image.png


justifyContent(FlexAlign.Center):元素在主轴方向中心对齐,第一个元素与行首的距离与最后一个元素与行尾距离相同。



@Entry
@Component
struct Index {
build() {

//justifyContent(FlexAlign.Center):元素在主轴方向中心对齐,第一个元素与行首的距离与最后一个元素与行尾距离相同
Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').height(300).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.Center)
}
}

image.png



  • justifyContent(FlexAlign.End):元素在主轴方向尾部对齐,最后一个元素与行尾对齐,其他元素与后一个对齐


@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.Spacebetween):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素与行首对齐,最后一个元素与行尾对齐
Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').height(300).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.End)

}
}

image.png
justifyContent(FlexAlign.Spacebetween):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素与行首对齐,最后一个元素与行尾对齐。



@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.Spacebetween):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素与行首对齐,最后一个元素与行尾对齐

Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').height(300).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.SpaceBetween)
}
}

image.png


justifyContent(FlexAlign.SpaceAround):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素到行首的距离和最后一个元素到行尾的距离是相邻元素之间距离的一半。


@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.SpaceAround):主轴方向均匀分配元素,相邻元素之间距离相同。
// 第一个元素到行首的距离和最后一个元素到行尾的距离是相邻元素之间距离的一半。


Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').height(300).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.SpaceAround)


}
}

image.png


justifyContent(FlexAlign.SpaceEvenly):主轴方向均匀分配元素,相邻元素之间的距离、第一个元素与行首的间距、最后一个元素到行尾的间距都完全一样。


@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.SpaceEvenly):主轴方向均匀分配元素,
// 相邻元素之间的距离、第一个元素与行首的间距、最后一个元素到行尾的间距都完全一样
Column({}) {
Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)

Column() {
}.width('80%').height(50).backgroundColor(0xD2B48C)

Column() {
}.width('80%').height(50).backgroundColor(0xF5DEB3)
}.width('100%').height(300).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.SpaceEvenly)


}
}

image.png


Row容器内子元素在主轴上的排列


justifyContent(FlexAlign.Start):元素在主轴方向首端对齐,第一个元素与行首对齐,同时后续的元素与前一个对齐



@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.Start):元素在主轴方向首端对齐,第一个元素与行首对齐,同时后续的元素与前一个对齐。
Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.Start)



}
}

image.png


justifyContent(FlexAlign.Center):元素在主轴方向中心对齐,第一个元素与行首的距离与最后一个元素与行尾距离相同



@Entry
@Component
struct Index {
build() {
// justifyContent(FlexAlign.Center):元素在主轴方向中心对齐,第一个元素与行首的距离与最后一个元素与行尾距离相同

Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.Center)

}
}

image.png


justifyContent(FlexAlign.Spacebetween):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素与行首对齐,最后一个元素与行尾对齐。



@Entry
@Component
struct Index {
build() {
// justifyContent(FlexAlign.End):元素在主轴方向尾部对齐,最后一个元素与行尾对齐,其他元素与后一个对齐。

Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.End)

}
}

image.png


justifyContent(FlexAlign.Spacebetween):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素与行首对齐,最后一个元素与行尾对齐


@Entry
@Component
struct Index {
build() {

//justifyContent(FlexAlign.Spacebetween):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素与行首对齐,最后一个元素与行尾对齐


Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.SpaceBetween)
}
}

image.png


justifyContent(FlexAlign.SpaceAround):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素到行首的距离和最后一个元素到行尾的距离是相邻元素之间距离的一半。



@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.SpaceAround):主轴方向均匀分配元素,相邻元素之间距离相同。第一个元素到行首的距离和最后一个元素到行尾的距离是相邻元素之间距离的一半

Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.SpaceAround)


}

}

image.png


justifyContent(FlexAlign.SpaceEvenly):主轴方向均匀分配元素,相邻元素之间的距离、第一个元素与行首的间距、最后一个元素到行尾的间距都完全一样


@Entry
@Component
struct Index {
build() {
//justifyContent(FlexAlign.SpaceEvenly):主轴方向均匀分配元素,相邻元素之间的距离、第一个元素与行首的间距、最后一个元素到行尾的间距都完全一样。

Row({}) {
Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)

Column() {
}.width('20%').height(30).backgroundColor(0xD2B48C)

Column() {
}.width('20%').height(30).backgroundColor(0xF5DEB3)
}.width('100%').height(200).backgroundColor('rgb(242,242,242)').justifyContent(FlexAlign.SpaceEvenly)
}

}

image.png


最后总结


arkui 写法和flutter非常的像 有兴趣的同学可以多尝试哈 今天的文章就讲到这里
。最后呢 希望我都文章能帮助到各位同学工作和学习 如果你觉得文章还不错麻烦给我三连 关注点赞和转发 谢谢


作者:xq9527
来源:juejin.cn/post/7301242165279047707
收起阅读 »

Android设置IPV4优先、httpdns使用

Android设置IPV4优先、httpdns使用 前言 最近接了个比较奇怪的BUG,就是服务器开了IPV6之后,部分安卓手机会访问不了,或者访问时间特别久,查了下是DNS会返回多个IP,但是IPV6地址会放在前面,比如: [ms.bdstatic.com/2...
继续阅读 »

Android设置IPV4优先、httpdns使用


前言


最近接了个比较奇怪的BUG,就是服务器开了IPV6之后,部分安卓手机会访问不了,或者访问时间特别久,查了下是DNS会返回多个IP,但是IPV6地址会放在前面,比如:


[ms.bdstatic.com/240e:95d:801:2::6fb1:624, ms.bdstatic.com/119.96.52.36]

然后取域名的时候默认会取第一个IP,然后就蛋疼了,有的机型、系统、运行商、路由器都可能不支持IPV6,然后访问不了。 由于iOS是没问题的,剩下来的肯定是Android的问题了。


于是我花了些时间看了看,做了个IPV4优先方案(还没用到生产环境),测试了下可行性,顺便又学了下httpdns的使用,这里记录下。


核心思路


网上找了资料,解决办法都是通过okhttp的自定义DNS去处理的(可以用Interceptor,不推荐),这个也是解决办法的核心:


class MyDns : Dns {
@Throws(UnknownHostException::class)
override fun lookup(hostname: String): List<InetAddress> {
return try {
val inetAddressList: MutableList<InetAddress> = ArrayList()
val inetAddresses = InetAddress.getAllByName(hostname)
Log.d("TAG", "lookup before: ${Arrays.toString(inetAddresses)}")
for (inetAddress in inetAddresses) {

// 将IPV4地址放到最前面
if (inetAddress is Inet4Address) {
inetAddressList.add(0, inetAddress)
} else {
inetAddressList.add(inetAddress)
}
}
Log.d("TAG", "lookup after: $inetAddressList")
inetAddressList
} catch (var4: NullPointerException) {
val unknownHostException = UnknownHostException("Broken system behavior")
unknownHostException.initCause(var4)
throw unknownHostException
}
}
}

上面自定义了一个DNS,里面的lookup就是okhttp查找DNS的逻辑,前面我okhttp源码的文章也有说到,默认会取第一个inetAddress,下面看下如何使用:


val client = OkHttpClient.Builder()
.dns(DnsInterceptor.MyDns())
.build()

// 异步请求下百度
client.newCall(Request.Builder().url(originalUrl).build()).enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d("TAG", "onFailure: ")
}

override fun onResponse(call: Call, response: Response) {
Log.d("TAG", "onResponse: $response")
}
}
)

看下log,第一个是我WiFi访问的,不支持IPV6,第二个是我用iPhone开热点访问的,支持IPV6:
dd.png


cc.png


ps. Android手机可以设置使用IPV6:



华为手机: 设置->移动网络->移动数据->接入点名称(APN)->新建一个APN,配置中的APN协议及APN漫游协议设置为仅ipv4或ipv6.



WebView内使用


okhttp好办,可是我们APP是套壳webView的,Android请求不多,大部分还是HttpURLConnection的,HttpURLConnection找了资料也不太好改,还不如改逻辑换成okhttp,但是webView就没得办法了。


好在API-21后,WebViewClient提供了新的shouldInterceptRequest方法,可以让我们代理它的请求操作,不过有很多限制操作。


shouldInterceptRequest方法


先来看下shouldInterceptRequest方法,它要求API大于等于21:


binding.webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
)
: WebResourceResponse? {
// ...
}
}

方法会提供一个request携带一些请求信息,要求我们返回一个WebResourceResponse,将代理的请求结果封装进去。鸡肋的就是这两个类东西都不多,会限制我们的代理功能:
dd.png


image.png


功能封装


这里我把代理功能封装了一下,可能还有问题,请谨慎参考:


import android.os.Build
import android.text.TextUtils
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import okhttp3.Dns
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Inet4Address
import java.net.InetAddress
import java.net.UnknownHostException
import java.nio.charset.Charset
import java.util.Arrays

object DnsInterceptor {

/**
* 设置okhttpClient
*/

lateinit var client: OkHttpClient

/**
* 拦截webView请求
*/

fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
)
: WebResourceResponse? {
// WebResourceRequest Android6.0以上才支持header,不支持body所以只能拦截GET方法
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& request.method.lowercase() == "get"
&& (request.url.scheme?.lowercase() == "http"
|| request.url.scheme?.lowercase() == "https")) {

// 获取头部
val headersBuilder = Headers.Builder()
request.requestHeaders.entries.forEach {
headersBuilder.add(it.key, it.value)
}
val headers = headersBuilder.build()

// 生成okhttp请求
val newRequest = Request.Builder()
.url(request.url.toString())
.headers(headers)
.build()

// 同步请求
val response = client.newCall(newRequest).execute()

// 对于无mime类型的请求不拦截
val contentType = response.body()?.contentType()
if (TextUtils.isEmpty(contentType.toString())) {
return null
}

// 获取响应头
val responseHeaders: MutableMap<String, String> = HashMap()
val length = response.headers().size()
for (i in 0 until length) {
val name = response.headers().name(i)
val value = response.headers().get(name)
if (null != value) {
responseHeaders[name] = value
}
}

// 创建新的response
return WebResourceResponse(
"${contentType!!.type()}/${contentType.subtype()}",
contentType.charset(Charset.defaultCharset())?.name(),
response.code(),
"OK",
responseHeaders,
response.body()?.byteStream()
)
} else {
return null
}
}

/**
* 优先使用ipv4
*/

class MyDns : Dns {
@Throws(UnknownHostException::class)
override fun lookup(hostname: String): List<InetAddress> {
return try {
val inetAddressList: MutableList<InetAddress> = ArrayList()
val inetAddresses = InetAddress.getAllByName(hostname)
Log.d("TAG", "lookup before: ${Arrays.toString(inetAddresses)}")
for (inetAddress in inetAddresses) {
if (inetAddress is Inet4Address) {
inetAddressList.add(0, inetAddress)
} else {
inetAddressList.add(inetAddress)
}
}
Log.d("TAG", "lookup after: $inetAddressList")
inetAddressList
} catch (var4: NullPointerException) {
val unknownHostException = UnknownHostException("Broken system behavior")
unknownHostException.initCause(var4)
throw unknownHostException
}
}
}
}

把大部分操作封装到一个单例类去了,然后在webView使用的时候就可以这样写:


// 创建okhttp
val client = OkHttpClient.Builder().dns(DnsInterceptor.MyDns()).build()
DnsInterceptor.client = client

// 配置webView
val webSettings = binding.webView.settings
webSettings.javaScriptEnabled = true //启用js,不然空白
webSettings.domStorageEnabled = true //getItem报错解决
binding.webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
)
: WebResourceResponse? {
try {
// 通过okhttp拦截请求
val response = DnsInterceptor.shouldInterceptRequest(view, request)
if (response != null) {
return response
}
}catch (e: Exception) {
// 可能有异常,发生异常就不拦截: UnknownHostException(MyDns)
e.printStackTrace()
}
return super.shouldInterceptRequest(view, request)
}
}

binding.button.setOnClickListener {
binding.webView.loadUrl(binding.ip.text.toString())
}

试了下,访问百度没啥问题


存在问题


上面方法虽然代理webView去发请求了,不过这里有好多限制:



  1. 需要API21以上,大部分机型应该满足

  2. 只能让GET请求优先使用IPV4,其他请求方法改不了

  3. 不支持MIME类型为空的响应

  4. 不支持contentType中,无法获取到编码的非二进制文件请求

  5. 不支持重定向


网上文章比较少,有几篇我看还都差不多,最后一对比,竟然是阿里云httpdns里面的说明,这里我也不太详叙了,看下文章吧:


Android端HTTPDNS+Webview最佳实践


HTTPDNS使用


上面修改DNS顺序的操作,实际和HTTPDNS的思路是一样的,看到相关内容后,触发了我知识的盲区,觉得还是有必要去学一学的。


HTTPDNS的作用就是代替本地的DNS解析,通过http请求访问httpdns的服务商,先拿到IP,再发起请求,可以防劫持,并且更快,当然这都是我简单的理解,可以看下阿里对它产品的介绍:



help.aliyun.com/document_de…



阿里HTTPDNS


这里我是选的阿里的httpdns服务,开通方式还是看他们自己的说明吧,不是很复杂: 服务开通


下面就来看如何使用,首先是添加依赖:


// setting.gradle.kts中
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven{ url = uri("./catalog_repo") }
maven {
url = uri("http://maven.aliyun.com/nexus/content/repositories/releases/")
name = "aliyun"
//一定要添加这个配置
isAllowInsecureProtocol = true
}
}
}

// 要使用的module中
implementation 'com.aliyun.ams:alicloud-android-httpdns:2.3.2'

这里是kts的依赖,groovy语法类似,gradle7.0以下甚至加个url就行。


再来看下具体使用,我在阿里云的后台配置了百度的域名(”http://www.baidu.com“),这里就来请求百度的IP:


val httpdns = HttpDns.getService(getContext(), "xxx")
// 预加载
httpdns.setPreResolveHosts(ArrayList(listOf("www.baidu.com")))

val originalUrl = "http://www.baidu.com"
val url = URL(originalUrl)
val ip = httpdns.getIpByHostAsync(url.host)
Log.d("TAG", "httpdns get init: ip = $ip")

这样使用我这直接就失败了,拿到的ip为null,所以初始化的操作应该要提前一点做:


// 点击事件
binding.button.setOnClickListener {
val ipClick = httpdns.getIpByHostAsync(url.host)
val ipv6 = httpdns.getIPv6sByHostAsync(url.host).let {
if (it.isNotEmpty()) return@let it[0]
else return@let "not get"
}
Log.d("TAG", "httpdns get: ip = $ipClick, ipv6 = $ipv6")
}

后面我把获取操作放到点击事件里面,就没问题了,也能拿到IPV6地址:
dd.png


这里要注意下,如果切换网络,IPV6的地址会有缓存,谨慎使用吧(网络可能不支持了):
dd.png


httpdns的使用应该算网络优化了吧,看别人文章说dns查找域名有的要几百毫秒,用httpdns可能只要一百毫秒,有机会来研究研究源码^_^


小结


稍微总结下吧,这篇文章分析了一下IPV6在Android上出错的原因,实践了下IPV4优先的思路,并且对webView做了支持,还研究了下httpdns的使用。


作者:方大可
来源:juejin.cn/post/7301573790342414351
收起阅读 »

移动端APP版本治理

1、背景 在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。 只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。 而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中...
继续阅读 »

1、背景


在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。


只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。


而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中间还有一个更新升级的空档期,多数公司在这里都是一个“三不管”地带,而这个空档期,我称之为版本交付的最后一公里



2、价值



2.1、业务侧


总有人会挑战“有什么业务价值?”对吧,那就先从业务价值来看。


尽管有些app的有些业务是动态发布的,但也一定会有些功能是依赖跟版的,也就是说,你没办法让所有用户都用上你的新功能,那对于产运团队来说,业务指标就还有提升的空间。


举两个例子:



  1. 饿了么免单活动需要8.+版本以上的app用户才能参与,现在参与用户占比80%,治理一波后,免单用户参与占比提升到90%,对业务来说,免单数没变,但是订单量却是有实实在在的提升的。

  2. 再来一个,酷狗音乐8.+的app用户才可以使用扫码登录,app低版本治理之后,扫码登录的用户占比势必也会提升,那相应的,登录成功率也可以提升,登录流程耗时也会缩短,这都是实实在在的指标提升。



虚拟数据,不具备真实参考性。



2.2、技术侧


说完业务看技术,在技术侧也可以分为三个维度来看:



  1. 稳定性,老版本的crash、anr之类的问题在新版本大概率是修复了的,疑难杂症可能久一点;

  2. 性能优化,比如启动、包大小、内存,可以预见是比老版本表现更好的,个别指标一两个版本可能会有微量劣化,但是一直开倒车的公司早晚会倒闭;

  3. 安全合规,不管是老的app版本还是老的服务接口,都可能会存在安全问题,那么黑产就可能抓住这个漏洞从而对我们服务的稳定性造成隐患,甚至产生资损;


2.3、其他方面


除了上面提到的业务指标和用户体验之外,还有没有?


有没有想过,老版本的用户升上来之后,那些兼容老版本的接口、系统服务等,是不是可以下线了,除了减少人力维护成本之外,还能减少机器成本啊,这也都是实打实的经费支出。


对于项目本身来说,也可以去掉一些无用代码,减少项目复杂度,提升健壮性、可维护性。


3、方案



3.1、升级交互


采用新的设计语言和新的交互方式。


3.1.1、弹窗样式


样式上要符合app整体的风格,信息展示明确,主次分明。


bad casegood case

3.1.2、操作表达


按钮的样式要凸显出来,并放在常规易操作的位置上。


bad casegood case

3.1.3、提醒链路


从一级菜单到二级页面的更新提醒链路,并保持统一。



3.1.4、进度感知


下载进度一定要可查看并准确,如果点了按钮之后什么提示都没有,用户会进入一个很迷茫的状态,体验很差。



3.2、提醒策略


我们需要针对不同的用户下发不同的提醒策略,这种更细致的划分,不光是为了稳定性和目标的达成,也是为了更好的用户体验,毕竟反复提醒容易引起用户的反感。


3.2.1、提醒时机


提醒时机其实是有讲究的,原则上是不能阻塞用户的行为。


特别是有强制行为的情况,比如强更,肯定不能在app启动就无脑拉起弹窗。



bad case:双十一那天,用户正争分夺秒准备下单呢,结果你让人升级,这不扯淡的吗。



时机的考虑上有两个维度:



  1. 平台:峰时谷时;

  2. 用户:闲时忙时;


3.2.2、逻辑引擎


为什么需要逻辑引擎呢?逻辑引擎的好处是跨端,双端逻辑可以保持一致,也可以动态下发。




可以使用接口平替,约定好协议即可。



3.2.3、软强更


强制更新虽然有可以预见的好效果,但是也伴随着投诉风险,要做好风险管控。


而在2023-02-27日,工信部更是发布了《关于进一步提升移动互联网应用服务能力》的通知,其中第四条更是明确表示“窗口关闭用户可选”,所以强更弹窗并不是我们的最佳选择。



虽然强更不可取,但是我们还可以提高用户操作的费力度,在取消按钮上增加倒计时,再根据低版本用户的分层,来配置不同的倒计时梯度,比如5s、10s、20s这样。


用户一样可以选择取消,但是却要等待一会,所以称之为软强更



3.2.4、策略字段



  • 标题

  • 内容

  • 最新版本号

  • 取消倒计时时长

  • 是否提醒

  • 是否强更

  • 双端最低支持的系统版本

  • 最大提醒次数

  • 未更新最大版本间隔

  • 等等


3.3、提示文案


升级文案属于是ROI很高的了,只需要总结一下新版本带来了哪些新功能、有哪些提升,然后配置一下就好了,但是对用户来说,却是实打实的信息冲击,他们可以明确的感知到新版本中的更新,对升级意愿会有非常大的提升。



虽然说roi很高,但是也要花点心思,特别是大的团队,需要多方配合。


首先是需求要有精简的价值点,然后运营同学整合所有的需求价值点,根据优先级,出一套面向用户的提醒话术,也就是提示的升级文案了。



3.4、更新渠道


iOS用户一般在App Store更新应用,但是对于Android来说,厂商比较多,对应的渠道也多,还有一些三方的,这些碎片化的渠道自然就把用户人群给分流了,为了让每一个用户都有渠道可以更新到最新版本,那就需要在渠道的运营上下点功夫了,尽可能的多覆盖。



除了拓宽更新渠道之外,在应用市场的介绍也要及时更新。


还有一点细节是,优化应用市场的搜索关键词。


最后,别忘了自有渠道-官网。


3.5、触达投放


如果我们做了上面这么多,还是有老版本的用户不愿意升级怎么办?我们还有哪些方式可以告诉他需要升级?


触达投放是一个很好的方式,就像游戏里的公告、全局喇叭一样,但是像发短信这种需要预算的方式一般都是放在最后使用的,尽可能的控制成本。




避免过度打扰,做好人群细分,控制好成本。



3.6、其他方案


类型介绍效果
更新引导更新升级操作的新手引导😀
操作手册描述整个升级流程,可以放在帮助页面,也可以放在联系客服的智能推荐🙂
营销策略升级给会员体验、优惠券之类的😀
内卷策略您获得了珍贵的内测机会,您使用的版本将领先同行98%😆
选择策略「88%的用户选择升级」,替用户做选择😆
心理策略「预计下载需要15秒」,给用户心理预期😀
接口拦截在使用某个功能或者登录的时候拦截,引导下载最新版本😆
自动下载设置里面加个开关,wifi环境下自动下载安装包😀
版本故事类似于微信,每个版本都有一个功能介绍的东西,点击跳转加上新手引导🙂

好的例子:


淘宝拼多多

4、长效治理


制定流程SOP,形成一个完整链路的组合拳打法🐶。


白话讲,就是每当一个新版本发布的时候,明确在每个时间段要做什么事,达成什么样的目标。


让更多需要人工干预的环节,都变成流程化,自动化。



5、最后


一张图总结APP版本治理依次递进的动作和策略。



作者:yechaoa
来源:juejin.cn/post/7299799127625170955
收起阅读 »

为什么稳定的大公司不向Flutter迁移?

迁移很难, 但从头开始很简单 从Flutter的测试版开始, 我就一直在关注它, 从那时起, 我就看到了Flutter在开发者, 社区和公司中的采用. 大多数新兴开发人员都曾多次讨论过这个问题: 为什么大公司不使用 Flutter? 因此, 这篇小文只是我...
继续阅读 »

迁移很难, 但从头开始很简单


从Flutter的测试版开始, 我就一直在关注它, 从那时起, 我就看到了Flutter在开发者, 社区和公司中的采用. 大多数新兴开发人员都曾多次讨论过这个问题:



为什么大公司不使用 Flutter?



因此, 这篇小文只是我对这个问题的个人观察和回答.


转向新技术既困难又复杂, 而从最新技术重新开始则很容易. 这(据我观察)也是为什么稳定的大公司不会将其长期使用的应用程序迁移到新技术的最大原因之一, 除非它能带来惊人的利润.


你会发现大多数初创公司都在使用 Flutter, 这是因为 90% 以上的跨平台应用程序新创意都可以在Flutter中以经济高效的方式轻松实现. Flutter中的一切都非常快速, 令人惊叹, 而且具有我们在过去几年的Flutter之旅中听说过的所有令人惊叹的优点.


那么问题来了:


既然Flutter如此令人惊叹, 高性价比, 单一代码库, 更轻松的单一团队管理, 令人愉悦的开发者体验, 等等等等; 那么为什么大公司不将他们的应用程序迁移到Flutter呢? 从头开始迁移或重写, 拥有单一团队, 单一代码, 这不就是天堂么?



不, 没那么简单.



问题所在: 业务vs技术热情


Flutter 令人惊叹, 你最终可以说服开发人员在公司中使用Flutter构建应用程序. 问题在于公司的运营业务. 企业希望Flutter能立即为业务做出贡献. 他们不希望等待自己的团队完全重写应用程序, 然后将其付诸实践以繁荣业务.


但正如我前面所说, 对于技术团队来说, 重写是最理想的. 因此, 这是一个可以由公司利益相关者共同思考的问题. 公司内部需要在分析领域, 业务类型, 团队文化等所有因素后, 找到一个中间立场.


无论如何, 让我们来看看这两种情况的结果如何.


从头开始重写: 技术方面


一家大公司拥有庞大的产品, 这些产品已融入流程和领域, 工作完美无瑕, 为企业完成了工作.


从技术上讲, 对首席技术官来说, 最好的办法是在Flutter上从头开始重写应用程序, 并将其完成. 但是, 如果他们决定这样做, 就必须雇佣一个全新的Flutter开发团队, 向他们解释当前产品/领域的所有情况, 并让他们重写应用程序. 这看起来很容易, 其实不然. 当前代码库中有很多内部知识必须传授给新团队, 这样他们才能为应用程序构建完全相同的体验/UI/UX/流程.



为什么构建完全相同的东西如此重要?



这是因为用户总有一天会第一次收到Flutter构建的应用程序更新, 这对于一个拥有成千上万用户的应用程序来说是非常危险的.


其次, 新功能正在当前应用的基础上构建, 重写后的应用可能无法赶上当前应用. 但这是一个商业决策(是停止新开发并先进行重写, 还是继续在当前应用程序中添加功能, 无论如何都要权衡利弊).


每家公司在领域, 文化, 人员, 领导力, 思维过程, 智囊团等方面都是独一无二的. 内部可能存在数以百计的挑战, 只有进入系统后才能了解. 你不可能对每家科技公司都提出一个单一的看法.


从头开始重写: 业务方面


公司在采用新事物时, 有一个非常重要的想法:



在重写的过程中, 业务不仅不应受到影响, 而且还应保持增长.



这意味着你不能在运行中的应用程序的功能和开发上妥协. 在重写版本中, 运行正常的程序不应出现错误. 为确保这一点, 需要进行严格的原子级测试, 以确保用户从一开始就掌握的功能不会出现任何问题(我们谈论的是大公司, 这意味着应用程序已运行多年). 我们可以进行单元/集成/用户界面测试, 但当它关系到业务, 金钱和用户时, 没有人会愿意冒这个险.


简而言之, 大多数稳定的公司不会决定从头开始用Flutter重写他们稳定的应用程序. 如果是基于项目的公司, 他们可能会使用Flutter启动新赢得的客户项目.


迁移(业务上友好, 技术上却不友好)


公司决定迁移到Flutter的另一种方式是逐屏迁移到 Flutter, 并使用与本地端(Talabat 的应用程序)通信的方法渠道. 对于技术团队来说, 这可能是一场噩梦, 但对于业务的持续运行, 以及让Flutter部分从一开始就为业务做出贡献来说, 这是最可行的方法(在重写过程中, 应用程序的Flutter部分除非上线到生产, 否则对业务没有任何用处).


作为一名读者和Flutter爱好者, 你可能会认为逐屏迁移非常了不起, 但实际上, 当你在一个每天有成千上万用户使用的生产应用程序中工作时, 这真的非常复杂. 这就像开颅手术.


总结一下


根据我的观察, 对于一家以产品为基础的公司来说, 决定将自己多年的移动开发技术栈转换为新的技术栈是非常困难的. 因此, 如果大公司真的决定转换, 这个决定本身确实值得称赞, 勇气可嘉, 也很有激励作用.


如果业务非常重要(如 Talabat, Foodpanda, 或涉及日常大量使用, 支付, 安全, 多供应商系统等的用户关键型应用程序), 那么从业务角度来看, 最理想的做法是以混合方式慢慢迁移应用程序. 同样, 这也不一定对所有人都可行, 重写可能更好. 这完全取决于公司和业务的结构以及决策的力度.


对于以项目为基础的公司来说, 使用Flutter启动新项目是最理想的选择(因为他们拥有热情洋溢, 不断壮大的团队, 并致力于融入新技术). 当他们使用Flutter构建新项目时, 如果交付速度比以前更快, 效率更高, 他们就会自动扩大业务.


对于开发人员和技术团队来说, 任何新技术都是令人惊叹的, 但如果你是一位在结构合理, 稳定的公司工作的工程师, 你也应该了解公司的业务视角, 从而理解他们对转用新技术的看法.


如果你是一名高级工程师/资深工程师, 你应该用他们更容易理解的语言向业务部门传达你的热情. 对于推介 Flutter, 可以是更少的时间, 更少的成本, 更少的努力, 更快的交付, 一个团队, 一个代码库, 更少的公关审查等(如果你是Flutter人员, 你已经知道了所有这些).



业务部门在做决定时必须考虑多个方面, 因此作为工程师, 要告诉他们一些能让他们更容易做出决定的事情.



以上是我的个人观点和看法, 如果你有不同的看法或经验, 请随时在评论中与我分享, 很乐意参与该问题的讨论.


Stay GOLD!


作者:bytebeats
来源:juejin.cn/post/7299731886498349107
收起阅读 »

聊天气泡图片的动态拉伸、镜像与适配

前情提要 春节又到了,作为一款丰富的社交类应用,免不了要上线几款和新年主题相关的聊天气泡背景。这不,可爱的兔兔和财神爷等等都安排上了,可是Android的气泡图上线流程等我了解后着实感觉有些许复杂,对比隔壁的iOS真是被吊打,且听我从头到尾细细详解一遍。 创建...
继续阅读 »

前情提要


春节又到了,作为一款丰富的社交类应用,免不了要上线几款和新年主题相关的聊天气泡背景。这不,可爱的兔兔和财神爷等等都安排上了,可是Android的气泡图上线流程等我了解后着实感觉有些许复杂,对比隔壁的iOS真是被吊打,且听我从头到尾细细详解一遍。


创建.9.png格式的图片


新建项目.png
在开发上图所示的功能中,我们一般都会使用 .9.png 图片,那么一张普通png格式的图片怎么处理成 .9.png 格式呢,一起来简单回顾下。


在Android Studio中,对一张普通png图片右键,然后点击 “Create 9-Patch file...”,选择新图片保存的位置后,双击新图就会显示图片编辑器,图片左侧的黑色线段可以控制图片的竖向拉伸区域,上侧的黑色线段可以控制图片的横向拉伸区域,下侧和右侧的黑色线段则可以控制内容的填充区域,编辑后如下图所示:
Snipaste_2023-01-11_15-04-08.png


上图呢是居中拉伸的情况,但是如果中间有不可拉伸元素的话如何处理呢(一般情况下我们也不会有这样的聊天气泡,这里是拜托UI小姐姐专门修改图片做的示例),如下图所示,这时候拉伸的话左侧和上侧就需要使用两条(多条)线段来控制拉伸的区域了,从而避免中间的财神爷被拉伸:
Snipaste_2023-01-11_16-10-53.png


OK,.9.png格式图片的处理就是这样了。


从资源文件夹加载.9.png图片


比如加载drawable或者mipmap资源文件夹中的图片,这种加载方式的话很简单,直接给文字设置背景就可以了,刚刚处理过的小兔子图片放在drawable-xxhdpi文件夹下,命名为rabbit.9.png,示例代码如下所示:


textView.background = ContextCompat.getDrawable(this, R.drawable.rabbit)

从本地文件加载“.9.png”图片


如果我们将上述rabbit.9.png图片直接放到应用缓存文件夹中,然后通过bitmap进行加载,伪代码如下:


textView.text = "直接加载本地.9.png图片"
textView.background =
BitmapDrawable.createFromPath(cacheDir.absolutePath + File.separator + "rabbit.9.png")

则显示效果如下:
Screenshot_2023-01-11-17-13-54-60.jpg


可以看到,这样是达不到我们想要的效果的,整张图片被直接进行拉伸了,完全没有我们上文设计的拉伸效果。


其实要想达到上文设计的居中拉伸效果,我们需要使用aapt工具对.9.png图片再进行下处理(在Windows系统上aapt工具所在位置为:你SDK目录\build-tools\版本号\aapt.exe),Windows下的命令如下所示:


.\aapt.exe s -i .\rabbit.9.png -o rabbit9.png

将处理过后新生成的rabbit9.png图片放入到应用缓存文件夹中,然后通过bitmap直接进行加载,代码如下:


textView.text = "加载经aapt处理过的本地图片"
textView.background =
BitmapDrawable.createFromPath(cacheDir.absolutePath + File.separator + "rabbit9.png")

则显示效果正常,如下所示:
Screenshot_2023-01-11-17-32-33-91_24cef02ef5c5f1a3ed9b56e4c5956272.jpg
也就是说如果我们需要从本地或者assets文件夹中加载可拉伸图片的话,那么整个处理的流程就是:根据源rabit.png图片创建rabbit.9.png图片 -> 使用aapt处理生成新的rabbit9.png图片。


项目痛点


所以,以上就是目前项目中的痛点,每次增加一个聊天气泡背景,Android组都需要从UI小姐姐那里拿两张图片,一左一右,然后分别处理成 .9.png 图,然后还需要用aapt工具处理,然后再上传到服务器。后台还需要针对Android和iOS平台下发不同的图片,这也太复杂了。
所以我们的目标就是只需要一张通用的气泡背景图,直接上传服务器,移动端下载下来后,在本地做 拉伸、镜像、缩放等 功能的处理,那么一起来探索下吧。


进阶探索


我们来先对比看下iOS的处理方式,然后升级我们的项目。


iOS中的方式


只需要一个原始的png的图片即可,人家有专门的resizableImage函数来处理拉伸,大致的示例代码如下所示:


let image : UIImage = UIImage(named: "rabbit.png")
image.resizableImage(withCapInsets: .init(top: 20, left: 20, right:20, bottom:20))

注意:这里的withCapInsets参数的含义应该是等同与Android中的padding。padding的区域就是被保护不会拉伸的区域,而剩下的区域则会被拉伸来填充。
可以看到这里其实是有一定的约束规范的,UI小姐姐是按照此规范来进行气泡图的设计的,所以我们也可以遵循大致的约束,和iOS使用同一张气泡背景图片即可。


Android中的探索


那么在Android中有没有可能也直接通过代码来处理图片的拉伸呢?也可以有!!!


原理请参考《Android动态布局入门及NinePatchChunk解密》,各种思想的碰撞请参考《Create a NinePatch/NinePatchDrawable in runtime》。


站在前面巨人的肩膀上看,最终我们需要自定义创建的就是一个NinePatchDrawable对象,这样可以直接设置给TextView的background属性或者其他drawable属性。那么先来看下创建该对象所需的参数吧:


/**
* Create drawable from raw nine-patch data, setting initial target density
* based on the display metrics of the resources.
*/

public NinePatchDrawable(
Resources res,
Bitmap bitmap,
byte[] chunk,
Rect padding,
String srcName
)

主要就是其中的两个参数:



  • byte[] chunk:构造chunk数据,是构造可拉伸图片的数据结构

  • Rect padding:padding数据,同xml中的padding含义,不要被Rect所迷惑


构造chunk数据


这里构造数据可是有说法的,我们先以上文兔子图片的拉伸做示例,在该示例中,横向和竖向都分别有一条线段来控制拉伸,那么我们定义如下:
横向线段的起点位置的百分比为patchHorizontalStart,终点位置的百分比为patchHorizontalEnd;
竖向线段的起点位置的百分比为patchVerticalStart,终点位置的百分比为patchVerticalEnd;
width和height分别为传入进来的bitmap的宽度和高度,示例代码如下:


private fun buildChunk(): ByteArray {

// 横向和竖向都只有一条线段,一条线段有两个端点
val horizontalEndpointsSize = 2
val verticalEndpointsSize = 2

val NO_COLOR = 0x00000001
val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output

val arraySize = 1 + 2 + 4 + 1 + horizontalEndpointsSize + verticalEndpointsSize + COLOR_SIZE
val byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())

byteBuffer.put(1.toByte()) //was translated
byteBuffer.put(horizontalEndpointsSize.toByte()) //divisions x
byteBuffer.put(verticalEndpointsSize.toByte()) //divisions y
byteBuffer.put(COLOR_SIZE.toByte()) //color size

// skip
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// padding 设为0,即使设置了数据,padding依旧可能不生效
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// skip
byteBuffer.putInt(0)

// regions 控制横向拉伸的线段数据
val patchLeft = (width * patchHorizontalStart).toInt()
val patchRight = (width * patchHorizontalEnd).toInt()
byteBuffer.putInt(patchLeft)
byteBuffer.putInt(patchRight)

// regions 控制竖向拉伸的线段数据
val patchTop = (height * patchVerticalStart).toInt()
val patchBottom = (height * patchVerticalEnd).toInt()
byteBuffer.putInt(patchTop)
byteBuffer.putInt(patchBottom)

for (i in 0 until COLOR_SIZE) {
byteBuffer.putInt(NO_COLOR)
}

return byteBuffer.array()
}

OK,上面是横向竖向都有一条线段来控制图片拉伸的情况,再看上文财神爷图片的拉伸示例,就分别都是两条线段控制了,也有可能需要更多条线段来控制,所以我们需要稍微改造下我们的代码,首先定义一个PatchRegionBean的实体类,该类定义了一条线段的起点和终点(都是百分比):


data class PatchRegionBean(
val start: Float,
val end: Float
)

在类中定义横向和竖向竖向线段的列表,用来存储这些数据,然后改造buildChunk()方法如下:


private var patchRegionHorizontal = mutableListOf<PatchRegionBean>()
private var patchRegionVertical = mutableListOf<PatchRegionBean>()

private fun buildChunk(): ByteArray {

// 横向和竖向端点的数量 = 线段数量 * 2
val horizontalEndpointsSize = patchRegionHorizontal.size * 2
val verticalEndpointsSize = patchRegionVertical.size * 2

val NO_COLOR = 0x00000001
val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output

val arraySize = 1 + 2 + 4 + 1 + horizontalEndpointsSize + verticalEndpointsSize + COLOR_SIZE
val byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())

byteBuffer.put(1.toByte()) //was translated
byteBuffer.put(horizontalEndpointsSize.toByte()) //divisions x
byteBuffer.put(verticalEndpointsSize.toByte()) //divisions y
byteBuffer.put(COLOR_SIZE.toByte()) //color size

// skip
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// padding 设为0,即使设置了数据,padding依旧可能不生效
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// skip
byteBuffer.putInt(0)

// regions 控制横向拉伸的线段数据
patchRegionHorizontal.forEach {
byteBuffer.putInt((width * it.start).toInt())
byteBuffer.putInt((width * it.end).toInt())
}

// regions 控制竖向拉伸的线段数据
patchRegionVertical.forEach {
byteBuffer.putInt((height * it.start).toInt())
byteBuffer.putInt((height * it.end).toInt())
}

for (i in 0 until COLOR_SIZE) {
byteBuffer.putInt(NO_COLOR)
}

return byteBuffer.array()
}

构造padding数据


对比刚刚的chunk数据,padding就显得尤其简单了,注意这里传递来的值依旧是百分比,而且需要注意别和Rect的含义搞混了即可:


fun setPadding(
paddingLeft: Float,
paddingRight: Float,
paddingTop: Float,
paddingBottom: Float,
)
: NinePatchDrawableBuilder {
this.paddingLeft = paddingLeft
this.paddingRight = paddingRight
this.paddingTop = paddingTop
this.paddingBottom = paddingBottom
return this
}

/**
* 控制内容填充的区域
* (注意:这里的left,top,right,bottom同xml文件中的padding意思一致,只不过这里是百分比形式)
*/

private fun buildPadding(): Rect {
val rect = Rect()

rect.left = (width * paddingLeft).toInt()
rect.right = (width * paddingRight).toInt()

rect.top = (height * paddingTop).toInt()
rect.bottom = (height * paddingBottom).toInt()

return rect
}

镜像翻转功能


因为是聊天气泡背景,所以一般都会有左右两个位置的展示,而这俩文件一般情况下都是横向镜像显示的,在Android中好像也没有直接的图片镜像功能,但好在之前做海外项目LTR以及RTL时候了解到一个投机取巧的方式,通过设置scale属性为-1来实现。这里我们同样可以这么做,因为最终处理的都是bitmap图片,示例代码如下:


/**
* 构造bitmap信息
* 注意:需要判断是否需要做横向的镜像处理
*/

private fun buildBitmap(): Bitmap? {
return if (!horizontalMirror) {
bitmap
} else {
bitmap?.let {
val matrix = Matrix()
matrix.setScale(-1f, 1f)
val newBitmap = Bitmap.createBitmap(
it,
0, 0, it.width, it.height,
matrix, true
)
it.recycle()
newBitmap
}
}
}

如果需要镜像处理我们就通过设置Matrix的scaleX的属性为-1f,这就可以做到横向镜像的效果,竖向则保持不变,然后通过Bitmap类创建新的bitmap即可。
图像镜像反转的情况下,还需要注意的两点是:



  • chunk的数据中横向内容需要重新处理

  • padding的数据中横向内容需要重新处理


/**
* chunk数据的修改
*/

if (horizontalMirror) {
patchRegionHorizontal.forEach {
byteBuffer.putInt((width * (1f - it.end)).toInt())
byteBuffer.putInt((width * (1f - it.start)).toInt())
}
} else {
patchRegionHorizontal.forEach {
byteBuffer.putInt((width * it.start).toInt())
byteBuffer.putInt((width * it.end).toInt())
}
}

/**
* padding数据的修改
*/

if (horizontalMirror) {
rect.left = (width * paddingRight).toInt()
rect.right = (width * paddingLeft).toInt()
} else {
rect.left = (width * paddingLeft).toInt()
rect.right = (width * paddingRight).toInt()
}

屏幕的适配


屏幕适配的话其实就是利用Bitmap的density属性,如果UI给定的图是按照480dpi设计的,那么就设置为480dpi或者相近的dpi即可:


// 注意:是densityDpi的值,320、480、640等
bitmap.density = 480

简单封装


通过上述两步重要的过程我们已经知道如何构造所需的chunk和padding数据了,那么简单封装一个类来处理吧,加载的图片我们可以通过资源文件夹(drawable、mipmap),asstes文件夹,手机本地文件夹来获取,所以对上述三种类型都做下支持:


/**
* 设置资源文件夹中的图片
*/

fun setResourceData(
resources: Resources,
resId: Int,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
val bitmap: Bitmap? = try {
BitmapFactory.decodeResource(resources, resId)
} catch (e: Throwable) {
e.printStackTrace()
null
}

return setBitmapData(
bitmap = bitmap,
resources = resources,
horizontalMirror = horizontalMirror
)
}

/**
* 设置本地文件夹中的图片
*/

fun setFileData(
resources: Resources,
file: File,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
val bitmap: Bitmap? = try {
BitmapFactory.decodeFile(file.absolutePath)
} catch (e: Throwable) {
e.printStackTrace()
null
}

return setBitmapData(
bitmap = bitmap,
resources = resources,
horizontalMirror = horizontalMirror
)
}

/**
* 设置assets文件夹中的图片
*/

fun setAssetsData(
resources: Resources,
assetFilePath: String,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
var bitmap: Bitmap?

try {
val inputStream = resources.assets.open(assetFilePath)
bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
} catch (e: Throwable) {
e.printStackTrace()
bitmap = null
}

return setBitmapData(
bitmap = bitmap,
resources = resources,
horizontalMirror = horizontalMirror
)
}

/**
* 直接处理bitmap数据
*/

fun setBitmapData(
bitmap: Bitmap?,
resources: Resources,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
this.bitmap = bitmap
this.width = bitmap?.width ?: 0
this.height = bitmap?.height ?: 0

this.resources = resources
this.horizontalMirror = horizontalMirror
return this
}

横向和竖向的线段需要支持多段,所以分别使用两个列表来进行管理:


fun setPatchHorizontal(vararg patchRegion: PatchRegionBean): NinePatchDrawableBuilder {
patchRegion.forEach {
patchRegionHorizontal.add(it)
}
return this
}

fun setPatchVertical(vararg patchRegion: PatchRegionBean): NinePatchDrawableBuilder {
patchRegion.forEach {
patchRegionVertical.add(it)
}
return this
}

演示示例


我们使用一个5x5的25宫格图片来进行演示,这样我们可以很方便的看出来拉伸或者边距的设置到底有没有生效,将该图片放入资源文件夹中,页面上创建一个展示该图片用的ImageView,假设图片大小是200x200,然后创建一个TextView,通过我们自己的可拉伸功能设置文字的背景。


(注:演示所用的图片是请UI小哥哥帮忙处理的,听完说完我的需求后,UI小哥哥二话没说当着我的面直接出了十来种颜色风格的图片让我选,相当给力!!!)


一条线段控制的拉伸


示例代码如下:


textView.width = 800
textView.background = NinePatchDrawableBuilder()
.setResourceData(
resources = resources,
resId = R.drawable.sample_1,
horizontalMirror = false
)
.setPatchHorizontal(
PatchRegionBean(start = 0.4f, end = 0.6f),
)
.build()

显示效果如下:
Screenshot_2023-01-13-17-52-29-22_24cef02ef5c5f1a3ed9b56e4c5956272.jpg
可以看到竖向上没有拉伸,横向上图片 0.4-0.6 的区域全部被拉伸,然后填充了800的宽度。


两条线段控制的拉伸


接下来再看这段代码示例,这里我们横向上添加了两条线段,分别是从0.2-0.4,0.6-0.8:


textView.width = 800
textView.background = NinePatchDrawableBuilder()
.setResourceData(
resources = resources,
resId = R.drawable.sample_1,
horizontalMirror = false
)
.setPatchHorizontal(
PatchRegionBean(start = 0.2f, end = 0.4f),
PatchRegionBean(start = 0.6f, end = 0.8f),
)
.build()

显示效果如下:
Screenshot_2023-01-13-17-35-49-40_24cef02ef5c5f1a3ed9b56e4c5956272.jpg
可以看到横向上中间的(0.4-0.6)的部分没有被拉伸,(0.2-0.4)以及(0.6-0.8)的部分被分别拉伸,然后填充了800的宽度。


padding的示例


我们添加上文字,并且结合padding来进行演示下,这里先设置padding距离边界都为0.2的百分比,示例代码如下:


textView.background = NinePatchDrawableBuilder()
.setResourceData(
resources = resources,
resId = R.drawable.sample_2,
horizontalMirror = false
)
.setPatchHorizontal(
PatchRegionBean(start = 0.4f, end = 0.6f),
)
.setPatchVertical(
PatchRegionBean(start = 0.4f, end = 0.6f),
)
.setPadding(
paddingLeft = 0.2f,
paddingRight = 0.2f,
paddingTop = 0.2f,
paddingBottom = 0.2f
)
.build()

显示效果如下:
Screenshot_2023-01-13-18-05-27-82_24cef02ef5c5f1a3ed9b56e4c5956272.jpg


然后将padding的边距都改为0.4的百分比,显示效果如下:
Screenshot_2023-01-13-18-05-49-15_24cef02ef5c5f1a3ed9b56e4c5956272.jpg


屏幕适配的示例


上述的图片都是在480dpi下显示的,这里我们将densityDpi设置为960,按道理来说拉伸图展示会小一倍,如下图所示:


textView.background = NinePatchDrawableBuilder()
......
.setDensityDpi(densityDpi = 960)
.build()

Screenshot_2023-01-14-19-18-35-82_24cef02ef5c5f1a3ed9b56e4c5956272.jpg


效果一览


整个工具类实现完毕后,又简单写了两个页面通过设置各种参数来实时预览图片拉伸和镜像以及padding的情况,效果展示如下:
zonghe.png


整体的探索过程到此基本就结束了,效果是实现了,然而性能和兼容性还无法保证,接下来需要进一步做下测试才能上线。可能有大佬很早就接触过这些功能,如果能指点下,鄙人则不胜感激。


文中若有纰漏之处还请大家多多指教。


参考文章



  1. Android 点九图机制讲解及在聊天气泡中的应用

  2. Android动态布局入门及NinePatchChunk解密

  3. Android点九图总结以及在聊天气泡中的使用


作者:乐翁龙
来源:juejin.cn/post/7188708254346641465
收起阅读 »

Android:监听滑动控件实现状态栏颜色切换

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。 今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图: 看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。



今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图:


1.gif


看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动到底部的时候背景色变为白色或者其他颜色的时候,状态栏颜色不跟随切换颜色有可能会显得难看至极。因此有了上图的效果,其实就是简单的实现了状态栏颜色切换的功能,效果看起来不至于那么尴尬。


首先,我们需要分析,其中需要用到哪些东西呢?



  • 沉浸式状态栏

  • 滑动组件监听


关于沉浸式状态栏,这里推荐使用immersionbar,一款非常不错的轮子。我们只需要将mannifests中主体配置为NoActionBar类型,再根据文档配置好状态栏颜色等属性即可快速得到沉浸式效果:


<style name="Theme.MyApplication" parent="Theme.AppCompat.Light.NoActionBar">

//基础设置
ImmersionBar.with(this)
.navigationBarColor(R.color.color_bg)
.statusBarDarkFont(true, 0.2f)
.autoStatusBarDarkModeEnable(true, 0.2f)//启用自动根据StatusBar颜色调整深色模式与亮式
.autoNavigationBarDarkModeEnable(true, 0.2f)//启用自动根据NavigationBar颜色调整深色式
.init()

//状态栏view
status_bar_view?.let {
ImmersionBar.setStatusBarView(this, it)
}

//xml中状态栏
<View
android:id="@+id/status_bar_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#b8bfff" />

关于滑动监听,我们都知道滑动控件有个监听滑动的方法OnScrollChangeListener,其中返回了Y轴滑动距离的参数。那么,我们可以根据这个参数进行对应的条件判断以达到动态修改状态栏的颜色。


scroll?.setOnScrollChangeListener { _, _, scrollY, _, _ ->
if (scrollY > linTop!!.height) {
if (!isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#ffffff")
)
isChange = true
}
} else {
if (isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#b8bfff")
)
isChange = false
}
}
}

这里判断滑动距离达到紫色视图末端时修改状态栏颜色。因为是在回调方法中,所以这里一旦滑动就在不停触发,所以给了一个私有属性进行不必要的操作,仅当状态改变时且滑动条件满足时才能修改状态栏。当然在这个方法内大家可以发挥自己的想象力做出更多的新花样来。


注意:



  • 滑动监听的这个方法只能在设备6.0及以上才能使用。

  • 需要初始化滑动控件的默认位置,xml中将焦点设置到其父容器中,防止滑动控件不再初始位置。


//初始化位置
scroll?.smoothScrollTo(0, 0)

//xml中设置父view焦点
android:focusable="true"
android:focusableInTouchMode="true"

好了,以上便是滑动控件中实现状态栏切换的简单实现,希望对大家有所帮助。


作者:似曾相识2022
来源:juejin.cn/post/7272229204870561850
收起阅读 »

货拉拉面试:全程八股!被问麻了

今天来看货拉拉 Java 技术岗的面试问题,废话不多说,先看问题。 一面问题 先让介绍项目,超卖问题项目是怎么实现的?有什么改进的想法? 线程池的核心参数? 在秒杀的过程中,比如只有 10 个名额,有 100 个人去抢,页面上需要做一些什么处理? Hash...
继续阅读 »

今天来看货拉拉 Java 技术岗的面试问题,废话不多说,先看问题。


一面问题




  1. 先让介绍项目,超卖问题项目是怎么实现的?有什么改进的想法?

  2. 线程池的核心参数?

  3. 在秒杀的过程中,比如只有 10 个名额,有 100 个人去抢,页面上需要做一些什么处理?

  4. HashSet 了解吗?

  5. HashMap 了解吗?从 0 个 put 20 个数据进去,整个过程是怎么样的?HashMap 扩容机制?是 put 12 个数据之前扩容还是之后扩容?什么时候装红黑树?为什么是 8 的时候转,为什么是 6 的时候退化回链表?

  6. ConcurrenHashMap 了解吗?用到哪些锁?

  7. CAS 原理了解吗?

  8. synchronized 有多少种锁?锁升级。

  9. MySQL 有哪些锁?

  10. 一条 SQL 执行的全流程?

  11. 地址输入 URL 到数据返回页面,整个流程?

  12. 域名服务器寻址?



二面问题




  1. 问了一下项目的锁,问怎么优化?

  2. 怎么进行项目部署的?

  3. 之前搭过最复杂的项目是什么?

  4. 你感觉这种架构有什么好处?为什么要进行微服务拆分?

  5. Nacos 用过吗?

  6. CAP 理论?Base 理论?

  7. MQ 用过吗?

  8. 有什么技术优势?



1.怎么解决超卖问题?


答:超卖问题是一个相对来说,比较经典且相对难处理的问题,解决它可以考虑从以下三方面入手:



  1. 前端初步限制:前端先做最基础的限制处理,只允许用户在一定时间内发送一次抢购请求。

  2. 后端限流:前端的限制只能针对部分普通用户,如果有恶意刷单程序,那么依靠前端是解决不了任何问题的,所以此时就需要后端做限流操作了,而后端的限流又分为以下手段:

    1. IP 限流:限制一个 IP 在一定时间内,只能发送一个请求。此技术实现要点:通过在 Spring Cloud Gateway 中编写自定义全局过滤器来实现 IP 限流。

    2. 接口限流:某个接口每秒只接受一定数量的请求。此技术实现要点:通过 Sentinel 的限流功能来实现。



  3. 排队处理:即时做了以上两步操作,仍然不能防止超卖问题的发生,此时需要使用分布式锁排队处理请求,才能真正的防止超卖问题的发生。此技术实现要点:

    1. 方案一:使用 Lua 脚本 + Redis 实现分布式锁。

    2. 方案二:使用 Redisson 实现分布式锁。





PS:关于这些技术实现细节,例如:Spring Cloud Gateway 全局自定义过滤器的实现、Sentinel 限流功能的实现、分布式锁 Redisson 的实现等,篇幅有限私信获取。



2.CAP 理论和 Base 理论?


CAP 理论


CAP 理论是分布式系统设计中的一个基本原则,它提供了一个思考和权衡一致性、可用性和分区容错性之间关系的框架。
CAP 理论的三个要素如下:



  1. 一致性(Consistency):在分布式系统中的多个副本或节点之间,保持数据的一致性。也就是说,如果有多个客户端并发地读取数据,在任何时间点上,它们都应该能够观察到相同的数据。

  2. 可用性(Availability):系统在任何时间点都能正常响应用户请求,即系统对外提供服务的能力。如果一个系统不能提供响应或响应时间过长,则认为系统不可用。

  3. 分区容忍性(Partition tolerance):指系统在遇到网络分区或节点失效的情况下,仍能够继续工作并保持数据的一致性和可用性。


CAP 理论指出,在分布式系统中,不能同时满足一致性、可用性和分区容错性这三个特性,只能是 CP 或者是 AP。



  • CP:强一致性和分区容错性设计。这样的系统要求保持数据的一致性,并能够容忍分区故障,但可用性较低,例如在分区故障期间无法提供服务。

  • AP:高可用性和分区容错性设计。这样的系统追求高可用性,而对一致性的要求较低。在分区故障期间,它可以继续提供服务,但数据可能会出现部分不一致。


CAP 无法全部满足的原因


CA 或 CAP 要求网络百分之百可以用,并且无延迟,否则在 C 一致性要求下,就必须要拒绝用户的请求,而拒绝了用户的请求就违背了 A 可用性,所以 CA 和 CAP 在分布式环境下是永无无法同时满足的,分布式系统要么是 CP 模式,要么是 AP 模式。


BASE 理论


BASE 理论是对分布式系统中数据的一致性和可用性进行权衡的原则,它是对 CAP 理论的一种补充。
BASE 是指:



  1. 基本可用性(Basically Available):系统保证在出现故障或异常情况下依然能够正常对外提供服务,尽管可能会有一定的性能损失或功能缺失。在分布式系统中,为了保证系统的可用性,有时会牺牲一致性。

  2. 软状态(Soft State):系统中的数据的状态并不是强一致的,而是柔性的。在分布式系统中,由于网络延迟、节点故障等因素,数据可能存在一段时间的不一致。

  3. 最终一致性(Eventually Consistent):系统会保证在一段时间内对数据的访问最终会达到一致的状态。即系统允许数据副本在一段时间内存在不一致的状态,但最终会在某个时间点达到一致。


BASE 理论强调系统的可用性和性能,尽可能保证系统持续提供服务,而不是追求强一致性。在实际应用中,为了降低分布式系统的复杂性和提高性能,可以采用一些方法来实现最终一致性,如版本管理、异步复制等技术手段。



PS:BASE 理论并不是对 CAP 理论的颠覆,而是对分布式系统在某些场景下的设计原则,在具体系统设计中,开发人员需要根据业务需求和场景来权衡和选择适当的一致性和可用性策略。



3.你有什么技术优势?


当面试官问你这个问题时,你可以从以下几个方面回答:



  1. 总结你掌握的技术点:首先,从你所应聘的职位和相关领域出发,总结并列出你的技术专长或专业专长。注意,你讲的这些技术点一定要向面试公司要求的技术点靠拢。

  2. 强调你的技术专长:在列举领域后,强调你在这些领域中的技术专长。你可以提及一些主要技术、框架等方面的技术专长。

  3. 举例说明:提供一些具体的项目案例或工作经验,展示你在技术领域上的实际应用能力。说明你如何使用所掌握的技术解决具体问题、优化系统性能或提升用户体验等。这样可以更加具体地说明你的技术优势,并证明你的技能在实践中是有价值的。

  4. 强调自己“软”优势:向面试官展示你的“软”优势,例如:喜欢专研技术(加上具体的例子,例如每天都有写代码提交到 GitHub)、积极学习和持续成长等能力。同时,强调你在团队合作中的贡献和沟通技巧等其他能力,这些也是技术优势的重要补充。



PS:其他常规的八股问题,可以在我的网站 http://www.javacn.site 找到答案,本文就不再赘述了,大家自己去看吧。



小结


货拉拉解决了日常生活中搬家难的痛点,也是属于某一个细分赛道的龙头企业了,公司不大,但算的上是比较知名的企业。他们公司的面试题并不难,以八股和项目中某个具体问题为主,只要好好准备,拿到他们公司的 Offer 还是比较简单的。


最后:祝大家都能拿到满意的 Offer。


作者:Java中文社群
来源:juejin.cn/post/7289333769236758569
收起阅读 »

首页弹框太多?Flow帮你“链”起来

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于 如何去判断它们什么时候出...
继续阅读 »

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于



  1. 如何去判断它们什么时候出来

  2. 它们出来的先后次序是什么

  3. 中途需求如果增加或者删除一个弹框或者页面,我们应该改动哪些逻辑


常见的做法


可能这种需求刚开始由于弹框少,交互还简单,所以大多数的做法就是直接在首页用if-else去完成了


if(条件1){
//弹框1
}else if(条件2){
//弹框2
}

但是当需求慢慢迭代下去,首页弹框越来越多,判断的逻辑也越来越复杂,判断条件之间还存在依赖关系的时候,我们的代码就变得很可怕了


if(条件1 && 条件2 && 条件3){
//弹框1
}else if(条件1 && (条件2 || 条件3)){
//弹框2
}else if(条件2 && 条件3){
//弹框3
}else if(....){
....
}

这种情况下,这些代码就变的越来越难维护,长久下来,造成的问题也越来越多,比如



  1. 代码可读性变差,不是熟悉业务的人无法去理解这些逻辑代码

  2. 增加或者减少弹框或者条件需要更改中间的逻辑,容易产生bug

  3. 每个分支的弹框结束后,需要重新从第一个if再执行一遍判断下一个弹框是哪一个,如果条件里面牵扯到io操作,也会产生一定的性能问题


设计思路


能否让每个弹框作为一个单独的任务,生成一条任务链,链上的节点为单个任务,节点维护任务执行的条件以及任务本身逻辑,节点之间无任何依赖关系,具体执行由任务链去管理,这样的话如果增加或者删除某一个任务,我们只需要插拔任务节点就可以


az1.png


定义任务


首先我们先简单定一个任务,以及需要执行的操作


interface SingleJob {
fun handle(): Boolean
fun launch(context: Context, callback: () -> Unit)
}


  • handle():判断任务是否应该执行的条件

  • launch():执行任务,并在任务结束后通过callback通知任务链执行下一条任务


实现任务


定义一个TaskJobOne,让它去实现SingleJob


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return true
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

这个任务里面,我们先默认handle的执行条件是true,一定会执行,实际开发过程中可以根据需要来定义条件,比如判断登录态等,lanuch里面我们简单的弹一个框,然后在弹框的关闭的时候,callback给任务链,为了调试的时候看的清楚一些,在这两个函数入口分别打印了日志,同样的任务我们再创建一个TaskJobTwo,TaskJobThree,具体实现差不多,就不贴代码了


任务链


首先思考下如何存放任务,由于任务之间需要体现出优先级关系,所以这里决定使用一个LinkedHashMap,K表示优先级,V表示任务


object JobTaskManager {
val jobMap = linkedMapOf(
1 to TaskJobOne(),
2 to TaskJobTwo(),
3 to TaskJobThree()
)
}

接着就是思考如何设计整条任务链的执行任务,因为这个是对jobMap里面的任务逐个拿出来执行的过程,所以我们很容易就想到可以用Flow去控制这些任务,但是有两个问题需要去考虑下



  1. 如果直接将jobMap转成Flow去执行,那么出现的问题就是所有任务全部都一次性执行完,显然不符合设计初衷

  2. 我们都知道Flow是由上游发送数据,下游接收并处理数据的一个自上而下的过程,但是这里我们需要一个job执行完以后,通过callback通知任务链去执行下一个任务,任务的发送是由前一个任务控制的,所以必须设计出一个环形的过程


首先我们需要一个变量去保存当前需要执行的任务优先级,我们定义它为curLevel,并设置初始值为1,表示第一个执行的是优先级为1的任务


var curLevel = 1

这个变量将会在任务执行完以后,通过callback回调以后再自增,至于自增之后如何再去执行下一条任务,这个通知的事情我们交给StateFlow


val stateFlow = MutableStateFlow(curLevel)
fun doJob(context: Context, job: SingleJob) {
if (job.handle()) {
job.launch(context) {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
} else {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
}

stateFlow初始值是curlevel,当上层开始订阅的时候,不给stateFlow设置value,那么stateFlow初始值1就会发送出去,开始执行优先级为1的任务,在doJob里面,当任务的执行条件不满足或者任务已经执行完成,就自增curLevel,再给stateFlow赋值,从而执行下一个任务,这样一个环形过程就有了,下面是在上层如何执行任务链


MainScope().launch {
JobTaskManager.apply {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}

我们的任务链就完成了,看下效果


a1111.gif


通过日志我们可以看到,的确是每次关闭一个弹框,才开始执行下一条任务,这样一来,如果某个任务的条件不满足,或者不想让它执行了,只需要改变对应job的handle条件就可以,比如现在把TaskJobOne的handel设置为false,在看下效果


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return false
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

a2222.gif


可以看到经过第一个task的时候,由于已经把handle条件设置成false了,所以直接跳过,执行下一个任务了


依赖于外界因素


上面只是简单的模拟了一个任务链的工作流程,实际开发过程中,我们有的任务会依赖于其他因素,最常见的就是必须等到某个接口返回数据以后才去执行,所以这个时候,执行你的任务需要判断的东西就更多了



  • 是否优先级已经轮到它了

  • 是否依赖于某个接口

  • 这个接口是否已经成功返回数据了

  • 接口数据是否需要传递给这个任务
    鉴于这些,我们就要重新设计我们的任务与任务链,首先要定义几个状态值,分别代表任务的不同状态


const val JOB_NOT_AVAILABLE = 100
const val JOB_AVAILABLE = 101
const val JOB_COMBINED_BY_NOTHING = 102
const val JOB_CANCELED = 103


  • JOB_NOT_AVAILABLE:该任务还没有达到执行条件

  • JOB_AVAILABLE:该任务达到了执行任务的条件

  • JOB_COMBINED_BY_NOTHING:该任务不关联任务条件,可直接执行

  • JOB_CANCELED:该任务不能执行


接着需要去扩展一下SingleJob的功能,让它可以设置状态,获取状态,并且可以传入数据


interface SingleJob {
......
/**
* 获取执行状态
*/

fun status():Int

/**
* 设置执行状态
*/

fun setStatus(level:Int)

/**
* 设置数据
*/

fun setBundle(bundle: Bundle)
}

更改一下任务的实现


class TaskJobOne : SingleJob {
var flag = JOB_NOT_AVAILABLE
var data: Bundle? = null
override fun handle(): Boolean {
println("start handle job one")
return flag != JOB_CANCELED
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
val type = data?.getString("dialog_type")
AlertDialog.Builder(context).setMessage(if(type != null)"这是第一个${type}弹框" else "这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
override fun setStatus(level: Int) {
if(flag != JOB_COMBINED_BY_NOTHING)
this.flag = level
}
override fun status(): Int = flag

override fun setBundle(bundle: Bundle) {
this.data = bundle
}
}

现在的任务执行条件已经变了,变成了状态不是JOB_CANCELED的任务才可以执行,增加了一个变量flag表示这个任务的当前状态,如果是JOB_COMBINED_BY_NOTHING表示不依赖外界因素,外界也不能改变它的状态,其余状态则通过setStatus函数来改变,增加了setBundle函数允许外界向任务传入数据,并且在launch函数里面接收数据并展示在弹框上,我们在任务链里面增加一个函数,用来给对应优先级的任务设置状态与数据


fun setTaskFlag(level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
}

我们现在可以把任务链同接口一起关联起来了,首先我们先创建个viewmodel,在里面创建三个flow,分别模拟三个不同接口,并且在flow里面向下游发送数据


class MainViewModel : ViewModel(){
val firstApi = flow {
kotlinx.coroutines.delay(1000)
emit("元宵节活动")
}
val secondApi = flow {
kotlinx.coroutines.delay(2000)
emit("端午节活动")
}
val thirdApi = flow {
kotlinx.coroutines.delay(3000)
emit("中秋节活动")
}
}

接着我们如果想要去执行任务链,就必须等到所有接口执行完毕才可以,刚好flow里面的zip操作符就可以满足这一点,它可以让异步任务同步执行,等到都执行完任务之后,才将数据传递给下游,代码实现如下


val mainViewModel: MainViewModel by lazy {
ViewModelProvider(this)[MainViewModel::class.java]
}

MainScope().launch {
JobTaskManager.apply {
mainViewModel.firstApi
.zip(mainViewModel.secondApi) { a, b ->
setTaskFlag( 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", a)
})
setTaskFlag( 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", b)
})
}.zip(mainViewModel.thirdApi) { _, c ->
setTaskFlag( 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", c)
})
}.collect {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}
}

运行一下,效果如下


a3333.gif


我们看到启动后第一个任务并没有立刻执行,而是等了一会才去执行,那是因为zip操作符是等所有flow里面的同步任务都执行完毕以后才发送给下游,flow里面已经执行完毕的会去等待还没有执行完毕的任务,所以才会出现刚刚页面有一段等待的时间,这样的设计一般情况下已经可以满足需求了,毕竟正常情况一个接口的响应时间都是毫秒级别的,但是难防万一出现一些极端情况,某一个接口响应忽然变慢了,就会出现我们的任务链迟迟得不到执行,产品体验方面就大打折扣了,所以需要想个方案解决一下这个问题


优化


首先我们需要当应用启动以后就立马执行任务链,判断当前需要执行任务的优先级与curLevel是否一致,另外,该任务的状态是可执行状态


/**
* 应用启动就执行任务链
*/

fun loadTask(context: Context) {
judgeJob(context, curLevel)
}

/**
* 判断当前需要执行任务的优先级是否与curLevel一致,并且任务可执行
*/

private fun judgeJob(context: Context, cur: Int) {
val job = jobMap[cur]
if(curLevel == cur && job?.status() != JOB_NOT_AVAILABLE){
MainScope().launch {
doJob(context, cur)
}
}
}

我们更改一下doJob函数,让它成为一个挂起函数,并且在里面执行完任务以后,直接去判断它的下一级任务应不应该执行


private suspend fun doJob(context: Context, index: Int) {
if (index > jobMap.size) return
val singleJOb = jobMap[index]
callbackFlow {
if (singleJOb?.handle() == true) {
singleJOb.launch(context) {
trySend(index + 1)
}
} else {
trySend(index + 1)
}
awaitClose { }
}.collect {
curLevel = it
judgeJob(context,it)
}
}

流程到了这里,如果所有任务都不依赖接口,那么这个任务链就能一直执行下去,如果遇到JOB_NOT_AVAILABLE的任务,需要等到接口响应的,那么任务链停止运行,那什么时候重新开始呢?就在我们接口成功回调之后给任务更改状态的时候,也就是setTaskFlag


fun setTaskFlag(context:Context,level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
judgeJob(context,level)
}

这样子,当任务链走到一个JOB_NOT_AVAILABLE的任务的时候,任务链暂停,当这个任务依赖的接口成功回调完成对这个任务状态的设置之后,再重新通过judgeJob继续走这条任务链,而一些优先级比较低的任务依赖的接口先完成了回调,那也只是完成对这个任务的状态更改,并不会执行它,因为curLevel还没到这个任务的优先级,现在可以试一下效果如何,我们把threeApi这个接口响应时间改的长一点


val thirdApi = flow {
kotlinx.coroutines.delay(5000)
emit("中秋节活动")
}

上层执行任务链的地方也改一下


MainScope().launch {
JobTaskManager.apply {
loadTask(this@MainActivity)
mainViewModel.firstApi.collect{
setTaskFlag(this@MainActivity, 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.secondApi.collect{
setTaskFlag(this@MainActivity, 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.thirdApi.collect{
setTaskFlag(this@MainActivity, 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
}
}

应用启动就loadTask,然后三个接口已经从同步又变成异步操作了,运行一下看看效果


a4444.gif


总结


大致的一个效果算是完成了,这只是一个demo,实际需求当中可能更复杂,弹框,页面,小气泡来回交互的情况都有可能,这里也只是想给一些正在优化项目的的同学提供一个思路,或者接手新需求的时候,鼓励多思考一下有没有更好的设计方案


作者:Coffeeee
来源:juejin.cn/post/7195336320435601467
收起阅读 »

Android RecyclerView — 实现自动加载更多

在App中,使用列表来显示数据是十分常见的。使用列表来展示数据,最好不要一次加载太多的数据,特别是带图片时,页面渲染的时间会变长,常见的做法是进行分页加载。本文介绍一种无感实现自动加载更多的实现方式。 实现自动加载更多 自动加载更多这个功能,其实就是在滑动列表...
继续阅读 »

在App中,使用列表来显示数据是十分常见的。使用列表来展示数据,最好不要一次加载太多的数据,特别是带图片时,页面渲染的时间会变长,常见的做法是进行分页加载。本文介绍一种无感实现自动加载更多的实现方式。


实现自动加载更多


自动加载更多这个功能,其实就是在滑动列表的过程中加载分页数据,这样在加载完所有分页数据之前就可以不停地滑动列表。


计算刷新临界点


手动加载更多一般是当列表滑动到当前最后一个Item后,再向上拖动RecyclerView控件来触发。不难看出来,最后一个Item就是一般加载更多功能的临界点,当达到临界点之后,继续滑动就加载分页数据。对于自动加载更多这个功能来说,如果使用最后一个Item作为临界点,就无法做到在加载完所有分页数据之前不停地滑动列表。那么自动加载更多这个功能的临界点应该是什么呢?


RecyclerView在手机屏幕上一次可显示的Item数量是有限的,相当于对所有Item进行了分页。当倒数第二页Item的最后一个Item显示在屏幕上时,是一个不错的加载下一分页数据的时机。



  • 获取RecyclerView的可视Item数量


通过LayoutManagerfindLastVisibleItemPosition()findFirstVisibleItemPosition()方法,可以计算出可视Item数量。


private fun calculateVisibleItemCount() {
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 可视Item数量
val visibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
}
}


  • 计算临界点


通过LayoutManagergetItemCount()方法,可以获取Item的总量。Item总量减一再减去可视Item数量就是倒数第二页Item的最后一个Item的位置。然后通过LayoutManagerfindViewByPosition()方法来获取临界点Item控件,当Item未显示时,返回值为null


private fun calculateCriticalPoint() {
(binding.rvExampleDataContainerVertical.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 可视Item数量
val visibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
// 临界点位置
val criticalPointPosition = (linearLayoutManager.itemCount - 1) - visibleItemCount
// 获取临界点Item的控件,未显示时返回null。
val criticalPointItemView = linearLayoutManager.findViewByPosition(criticalPointPosition)
}
}

监听列表滑动


通过RecyclerViewaddOnScrollListener()方法,可以对RecyclerView添加滑动监听。在滑动监听中的回调里,可以对RecyclerView的滑动方向以及是否达到了临界点进行判断,当达到临界点时就可以加载下一页的分页数据。代码如下:


private fun checkLoadMore() {
binding.rvExampleDataContainerVertical.addOnScrollListener(object : RecyclerView.OnScrollListener() {

private var scrollToEnd = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 判断是拖动或者惯性滑动
if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) {
// 可视Item数量
val visibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
// 临界点位置
val criticalPointPosition = (linearLayoutManager.itemCount - 1) - visibleItemCount
// 获取临界点Item的控件,未显示时返回null。
val criticalPointItemView = linearLayoutManager.findViewByPosition(criticalPointPosition)
// 判断是向着列表尾部滚动,并且临界点已经显示,可以加载更多数据。
if (scrollToEnd && criticalPointItemView != null) {
// 加载更多数据
......
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
scrollToEnd = if (linearLayoutManager.orientation == LinearLayoutManager.VERTICAL) {
// 竖向列表判断向下滑动
dy > 0
} else {
// 横向列表判断向右滑动
dx > 0
}
}
}
})
}

完整演示代码



  • 适配器


class AutoLoadMoreExampleAdapter(private val vertical: Boolean = true) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

private val containerData = ArrayList<String>()

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RecyclerView.ViewHolder {
return if (vertical) {
AutoLoadMoreItemVerticalViewHolder(LayoutAutoLoadMoreExampleItemVerticalBinding.inflate(LayoutInflater.from(parent.context), parent, false))
} else {
AutoLoadMoreItemHorizontalViewHolder(LayoutAutoLoadMoreExampleItemHorizontalBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
}

override fun getItemCount(): Int {
return containerData.size
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is AutoLoadMoreItemVerticalViewHolder -> {
holder.itemViewBinding.tvTextContent.text = containerData[position]
}

is AutoLoadMoreItemHorizontalViewHolder -> {
holder.itemViewBinding.tvTextContent.text = containerData[position]
}
}
}

fun setNewData(newData: ArrayList<String>) {
val currentItemCount = itemCount
if (currentItemCount != 0) {
containerData.clear()
notifyItemRangeRemoved(0, currentItemCount)
}
if (newData.isNotEmpty()) {
containerData.addAll(newData)
notifyItemRangeChanged(0, itemCount)
}
}

fun addData(newData: ArrayList<String>) {
val currentItemCount = itemCount
if (newData.isNotEmpty()) {
this.containerData.addAll(newData)
notifyItemRangeChanged(currentItemCount, itemCount)
}
}

class AutoLoadMoreItemVerticalViewHolder(val itemViewBinding: LayoutAutoLoadMoreExampleItemVerticalBinding) : RecyclerView.ViewHolder(itemViewBinding.root)

class AutoLoadMoreItemHorizontalViewHolder(val itemViewBinding: LayoutAutoLoadMoreExampleItemHorizontalBinding) : RecyclerView.ViewHolder(itemViewBinding.root)
}


  • 示例页面


class AutoLoadMoreExampleActivity : AppCompatActivity() {

private val prePageCount = 20

private var verticalRvVisibleItemCount = 0

private val verticalRvAdapter = AutoLoadMoreExampleAdapter()

private val verticalRvScrollListener = object : RecyclerView.OnScrollListener() {

private var scrollToBottom = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 判断是拖动或者惯性滑动
if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) {
if (verticalRvVisibleItemCount == 0) {
// 获取列表可视Item的数量
verticalRvVisibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
}
// 判断是向着列表尾部滚动,并且临界点已经显示,可以加载更多数据。
if (scrollToBottom && linearLayoutManager.findViewByPosition(linearLayoutManager.itemCount - 1 - verticalRvVisibleItemCount) != null) {
loadData()
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
// 判断列表是向列表尾部滚动
scrollToBottom = dy > 0
}
}

private var horizontalRvVisibleItemCount = 0

private val horizontalRvAdapter = AutoLoadMoreExampleAdapter(false)

private val horizontalRvScrollListener = object : RecyclerView.OnScrollListener() {

private var scrollToEnd = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 判断是拖动或者惯性滑动
if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) {
if (horizontalRvVisibleItemCount == 0) {
// 获取列表可视Item的数量
horizontalRvVisibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
}
// 判断是向着列表尾部滚动,并且临界点已经显示,可以加载更多数据。
if (scrollToEnd && linearLayoutManager.findViewByPosition(linearLayoutManager.itemCount - 1 - horizontalRvVisibleItemCount) != null) {
loadData()
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
// 判断列表是向列表尾部滚动
scrollToEnd = dx > 0
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = LayoutAutoLoadMoreExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.includeTitle.tvTitle.text = "AutoLoadMoreExample"

binding.rvExampleDataContainerVertical.adapter = verticalRvAdapter
binding.rvExampleDataContainerVertical.addOnScrollListener(verticalRvScrollListener)

binding.rvExampleDataContainerHorizontal.adapter = horizontalRvAdapter
binding.rvExampleDataContainerHorizontal.addOnScrollListener(horizontalRvScrollListener)

loadData()
}

fun loadData() {
val init = verticalRvAdapter.itemCount == 0
val start = verticalRvAdapter.itemCount
val end = verticalRvAdapter.itemCount + prePageCount

val testData = ArrayList<String>()
for (index in start until end) {
testData.add("item$index")
}
if (init) {
verticalRvAdapter.setNewData(testData)
horizontalRvAdapter.setNewData(testData)
} else {
verticalRvAdapter.addData(testData)
horizontalRvAdapter.addData(testData)
}
}
}

效果如图:


Screen_recording_202 -middle-original.gif

可以看见,分页设定为每页20条数据,列表可以在滑动中无感的实现加载更多。


示例Demo


演示代码已在示例Demo中添加。


ExampleDemo github


ExampleDemo gitee


作者:ChenYhong
来源:juejin.cn/post/7294638699417288714
收起阅读 »

RecyclerView 低耦合单选、多选模块实现

前言 需求很简单也很常见,比如有一个数据列表RecyclerView,需要用户去点击选择一个或多个数据。 实现单选的时候往往简单下标记录了事,实现多选的时候就稍微复杂去处理集合和选中。随着项目选中需求增多,不同的地方有了不同的实现,难以维护。 因此本文设计和实...
继续阅读 »

前言


需求很简单也很常见,比如有一个数据列表RecyclerView,需要用户去点击选择一个或多个数据。


实现单选的时候往往简单下标记录了事,实现多选的时候就稍微复杂去处理集合和选中。随着项目选中需求增多,不同的地方有了不同的实现,难以维护。


因此本文设计和实现了简单的选择模块去解决此类需求。


本文实现的选择模块主要有以下特点:



  • 不需要改动Adapter,ViewHolder,Item,低耦合

  • 单选,可监听选择变化,手动设置选择位置,支持配置再次点击取消选择

  • 多选,支持全选,反选等

  • 支持数据变化后记录原选择


项目地址 BindingAdapter


效果


img1.jpg
img5.jpg
img4.jpg
import me.lwb.adapter.select.isItemSelected

class XxxActivity {
private val dataAdapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->
itemBinding.tips.text = item
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}

fun onCreate() {
val selectModule = dataAdapter.setupSingleSelectModule()//单选
val selectModule = dataAdapter.setupMultiSelectModule()//多选

selectModule.doOnSelectChange {

}
//...全选,反选等
}
}

原理


单选


单选的特点:



  1. 用户点击可以选中列表的一个元素 。

  2. 当选择另1个数据会自动取消当前已经选中的,也就是最多选中1个。

  3. 再次点击已经选中的元素取消选中(可配置)。


根据记录选中数据的不同,可以分为下标模式和标识模式,他们各有优缺点。


下标模式


通常情况我们都会这样实现。使用一个记录选中下标的变量selectIndex去标识当前选择,selectIndex=-1表示没有选中任何元素。


原理虽然简单,那么问题来了,变量selectIndex应该放在哪里呢? Adapter?Fragment?Activity?


往往许多人都会选择放在Adapter,觉得数据选中和数据放一起嘛。


实现是实现了,但是往往有更多问题:



  1. 给一个列表增加数据选择功能,需要改造Adapter,侵入性强。

  2. 我要给另外一个列表增加数据选择功能,需要再实现一遍,难复用。

  3. 去除数据选择功能,又需要再改动Adapter,耦合重。


总结起来其实这样实现是不符合单一职责的原则,selectIndex是数据选择功能的数据,Adapter是绑定UI数据的。放在一起改动一方就得牵扯到另外一方。


解决办法就是,单独抽离出选择模块,依赖于Adapter的接口而不是放在Adapter中实现。


得益于BindingAdapter提供的接口,我们首先通过doBeforeBindViewHolder 在绑定时添加Item点击事件的监听,然后切换selectIndex


我们将需要保存的选择数据和行为,单独放在一个模块:


class SingleSelectModule {
var selectIndex: Int
var enableUnselect: Boolean

init {
adapter.doBeforeBindViewHolder { holder, position ->
holder.itemView.setOnClickListener {
toogleSelect(position)
}
}
}

fun toggleSelect(selectedKey: Int) {
selectIndex = if (enableUnselect) {
if (isSelected(selectedKey)) {
INDEX_UNSELECTED //取消选择
} else {
selectedKey //切换选择
}
} else {
selectedKey //切换选择
}
}
//...
}


往往我们需要在onBindViewHolder时判断当前Item是否选中,从而对选中和未选中的Item显示不同的样式。


简单的实现的话可以保存SingleSelectModule引用,然后再onBindViewHolder中获取。


class XxActivity {
var selectModule: SingleSelectModule
val adapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { pos, item ->
val isItemSelected = selectModule.isSelected(pos)
itemBinding.tips.text = item
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}
}

但缺点就是,它又和SingleSelectModule产生了耦合,实际上我们只需要关心当前Item
是否选中即可,要是能给Item加个isItemSelected 属性就好了。


许多的选择方案确实是这么实现的,给Item 添加属性,或者使用Pair<Boolean,Item>去包装,这些方案又造成了一定的侵入性。
我们从另外一个角度,不从Item入手,而是从ViewHolder中去改造,比如这样:


class BindingViewHolder {
var isItemSelected: Boolean
}

ViewHolder加属性比Item更加通用,起码不用每个需要支持选择的列表都去改造Item


但是逻辑上需要注意:真正选中的是Item,而不是ViewHolder,因为ViewHolder
可能会在不同的时机绑定到不同的Item


所以实际上BindingViewHolder.isItemSelected起到一个桥接作用,
原本的onBindViewHolder内容,是通过val isItemSelected = selectModule.isSelected(pos)获取当前Item是否选中,然后再去使用isItemSelected


现在我们将变量加到ViewHolder后,就不用每次去定义变量了。


    val adapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { pos, item ->
this.isItemSelected = selectModule.isSelected(pos)
itemBinding.tips.text = item
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}

同时再把赋值isItemSelected = selectModule.isSelected(pos) 也放入到选择模块中


class SingleSelectModule {

init {
adapter.doBeforeBindViewHolder { holder, position ->
holder.isItemSelected = this.isSelected(pos)
holder.itemView.setOnClickListener {
toogleSelect(position)
}
}
}
}


doBeforeBindViewHolder 可以在监听Adapter的onBindViewHolder,并在其前面执行



最后这里就剩下一个问题了,给BindingViewHolder增加isItemSelected 不是又得改ViewHolder吗。还是造成了侵入性,
后续我们还得增加其他模块,总不能每增加一个模块就改一次ViewHolder吧。


那么如何动态的增加属性?


这里我们直接就想到了通过view.setTag/view.getTag(本质上是SparseArray)不就能实现动态添加属性吗,
同时利用上Kotlin的拓展属性,那么它就成了真的"拓展属性"了:


var BindingViewHolder<*>.isItemSelected: Boolean
set(value) {
itemView.setTag(R.id.binding_adapter_view_holder_tag_selected, value)
}
get() = itemView.getTag(R.id.binding_adapter_view_holder_tag_selected) == true

然后通过引入这个拓展属性import me.lwb.adapter.select.isItemSelected 就能直接在Adapter中访问了,
同理你可以添加任意个拓展属性,并通过doBeforeBindViewHolder来在它们被使用前赋值,这些都不需要改动Adapter或者ViewHolder


import me.lwb.adapter.select.isItemSelected
import me.lwb.adapter.select.isItemSelected2
import me.lwb.adapter.select.isItemSelected3

class XxActivity {
private val dataAdapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->
//使用isItemSelected isItemSelected2 isItemSelected3

itemBinding.tips.text = item++
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}

}

下标模式十分易用,只需一行代码即可setupSingleSelectModule,但是也有一定局限性,就是用户选中的数据是使用下标来记录的,


如果数据下标对应的数据是变化了,就往往不是我们预期的效果,比如[A,B,C,D],用户选择B,此时selectIndex=1,用户刷新数据变成了[D,C,B,A],这时由于selectIndex=1,虽然选择的都是第2个,但是数据变化了,就变成了选择了C


往往那么经常就只能清空选择了。


标识模式


下标模式适用于数据不变,或者变化后清空选中的情况。


标识模式就是记录数据的唯一标识,可以在数据变化后仍然选中对应的数据,一般Item都会有一个唯一Id可以用作标识。


实现和下标模式接近,但是需要实现获取标识的方法,并且判断选中是根据标识是否相同。


class SingleSelectModuleByKey<I : Any> internal constructor(
val adapter: MultiTypeBindingAdapter<I, *>,
val selector: I.() -> Any,
){

fun isSelected(selectedKey: I?): Boolean {
val select = selectedItem
return selectedKey != ITEM_UNSELECTED && select != ITEM_UNSELECTED && selectedKey.selector() == select.selector()
}
}

使用时指定Item的标识:


adapter.setupSingleSelectModuleByKey { it.id }

多选


多选也分为下标模式和标识模式,原理和单选类似


下标模式


存储选中状态从下标变成了下标集合


class MultiSelectModule<I : Any> internal constructor(
val adapter: MultiTypeBindingAdapter<I, *>,
) {
private val mutableSelectedIndexes: MutableSet<Int> = HashSet();
override fun isSelected(selectKey: Int): Boolean {
return selectedIndexes.contains(selectKey)
}
override fun selectItem(selectKey: Int, choose: Boolean) {
if (choose) {
mutableSelectedIndexes.add(selectKey)
} else {
mutableSelectedIndexes.remove(selectKey)
}
notifyItemsChanged()
}
//全选
override fun selectAll() {
mutableSelectedIndexes.clear()
//添加所有索引
for (i in 0 until adapter.itemCount) {
mutableSelectedIndexes.add(i)
}
notifyItemsChanged()
}

//反选
override fun invertSelected() {
val selectStates = BooleanArray(adapter.itemCount) { false }
mutableSelectedIndexes.forEach {
selectStates[it] = true
}
mutableSelectedIndexes.clear()
selectStates.forEachIndexed { index, select ->
if (!select) {
mutableSelectedIndexes.add(index)
}
}
notifyItemsChanged()
}
}


标识模式


存储选中状态从标识变成了标识集合


class SingleSelectModuleByKey<I : Any> internal constructor(
override val adapter: MultiTypeBindingAdapter<I, *>,
val selector: I.() -> Any,
) {
private val mutableSelectedItems: MutableMap<Any, IndexedValue<I>> = HashMap()
override fun isSelected(selectKey: I): Boolean {
return mutableSelectedItems.containsKey(selectKey.selector())
}
override fun selectItem(selectKey: I, choose: Boolean) {
val id = selectKey.selector()
if (choose) {
mutableSelectedItems[id] = IndexedValue(selectKey)
} else {
mutableSelectedItems.remove(id)
}
notifyItemsChanged()
}
//全选
override fun selectAll() {
mutableSelectedItems.clear()
mutableSelectedItems.putAll(adapter.data.mapIndexed { index, it ->
it.selector() to IndexedValue(it, index)
})
notifyItemsChanged()
}
//反选
override fun invertSelected() {
val other = adapter.data
.asSequence()
.filter { it !in mutableSelectedItems }
.mapIndexed { index, it -> it.selector() to IndexedValue(it, index) }
.toList()

mutableSelectedItems.clear()
mutableSelectedItems.putAll(other)

notifyItemsChanged()
}
}

使用上也是类似的


val selectModule = dataAdapter.setupMultiSelectModule()
val selectModule = dataAdapter.setupMultiSelectModuleByKey()

总结


本文实现了在RecyclerView中使用的独立的单选,多选模块,有下标模式标识模式基本能满足项目中的需求。
利用BindingAdapter提供的接口,使得添加选择模块几乎是拔插式的。
同时,由于RadioGr0upTabLayout更新数据麻烦,需要重写removeadd。因此许多情况下RecyclerView也可以代替RadioGr0upTabLayout使用


本文的实现和Demo均可在项目中找到。


项目地址 BindingAdapter


作者:丨小夕
来源:juejin.cn/post/7246657502842077245
收起阅读 »

Android 使用AIDL传输超大型文件

最近在写车载Android的第5篇视频教程「AIDL的实践与封装」时,遇到一个有意思的问题,能不能通过AIDL传输超过 1M 以上的文件? 我们先不细究,为什么要用AIDL传递大文件,单纯从技术的角度考虑能不能实现。众所周知,AIDL是一种基于Binder实现...
继续阅读 »

最近在写车载Android的第5篇视频教程「AIDL的实践与封装」时,遇到一个有意思的问题,能不能通过AIDL传输超过 1M 以上的文件?


我们先不细究,为什么要用AIDL传递大文件,单纯从技术的角度考虑能不能实现。众所周知,AIDL是一种基于Binder实现的跨进程调用方案,Binder 对传输数据大小有限制,传输超过 1M 的文件就会报 android.os.TransactionTooLargeException 异常。


如果文件相对比较小,还可以将文件分片,大不了多调用几次AIDL接口,但是当遇到大型文件或超大型文件时,这种方法就显得耗时又费力。好在,Android 系统提供了现成的解决方案,其中一种解决办法是,使用AIDL传递文件描述符ParcelFileDescriptor,来实现超大型文件的跨进程传输。


ParcelFileDescriptor


ParcelFileDescriptor 是一个实现了 Parcelable 接口的类,它封装了一个文件描述符 (FileDescriptor),可以通过 Binder 将它传递给其他进程,从而实现跨进程访问文件或网络套接字。ParcelFileDescriptor 也可以用来创建管道 (pipe),用于进程间的数据流传输。


ParcelFileDescriptor 的具体用法有以下几种:




  • 通过 ParcelFileDescriptor.createPipe() 方法创建一对 ParcelFileDescriptor 对象,分别用于读写管道中的数据,实现进程间的数据流传输。




  • 通过 ParcelFileDescriptor.fromSocket() 方法将一个网络套接字 (Socket)转换为一个 ParcelFileDescriptor 对象,然后通过 Binder 将它传递给其他进程,实现跨进程访问网络套接字。




  • 通过 ParcelFileDescriptor.open() 方法打开一个文件,并返回一个 ParcelFileDescriptor 对象,然后通过 Binder 将它传递给其他进程,实现跨进程访问文件。




  • 通过 ParcelFileDescriptor.close() 方法关闭一个 ParcelFileDescriptor 对象,释放其占用的资源。




ParcelFileDescriptor.createPipe()和ParcelFileDescriptor.open() 都可以实现,跨进程文件传输,接下来我们会分别演示。


实践



  • 第一步,定义AIDL接口


interface IOptions {
void transactFileDescriptor(in ParcelFileDescriptor pfd);
}


  • 第二步,在「传输方」使用ParcelFileDescriptor.open实现文件发送


private void transferData() {
try {
// file.iso 是要传输的文件,位于app的缓存目录下,约3.5GB
ParcelFileDescriptor fileDescriptor = ParcelFileDescriptor.open(new File(getCacheDir(), "file.iso"), ParcelFileDescriptor.MODE_READ_ONLY);
// 调用AIDL接口,将文件描述符的读端 传递给 接收方
options.transactFileDescriptor(fileDescriptor);
fileDescriptor.close();

} catch (Exception e) {
e.printStackTrace();
}
}


  • 或,在「传输方」使用ParcelFileDescriptor.createPipe实现文件发送


ParcelFileDescriptor.createPipe 方法会返回一个数组,数组中的第一个元素是管道的读端,第二个元素是管道的写端。


使用时,我们先将「读端-文件描述符」使用AIDL发给「接收端」,然后将文件流写入「写端」的管道即可。


    private void transferData() {
try {
/******** 下面的方法也可以实现文件传输,「接收端」不需要任何修改,原理是一样的 ********/
// createReliablePipe 创建一个管道,返回一个 ParcelFileDescriptor 数组,
// 数组中的第一个元素是管道的读端,
// 第二个元素是管道的写端
ParcelFileDescriptor[] pfds = ParcelFileDescriptor.createReliablePipe();
ParcelFileDescriptor pfdRead = pfds[0];
// 调用AIDL接口,将管道的读端传递给 接收端
options.transactFileDescriptor(pfdRead);
ParcelFileDescriptor pfdWrite = pfds[1];
// 将文件写入到管道中
byte[] buffer = new byte[1024];
int len;
try (
// file.iso 是要传输的文件,位于app的缓存目录下
FileInputStream inputStream = new FileInputStream(new File(getCacheDir(), "file.iso"));
ParcelFileDescriptor.AutoCloseOutputStream autoCloseOutputStream = new ParcelFileDescriptor.AutoCloseOutputStream(pfdWrite);
) {
while ((len = inputStream.read(buffer)) != -1) {
autoCloseOutputStream.write(buffer, 0, len);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}


注意,管道写入的文件流 总量限制在64KB,所以「接收方」要及时将文件从管道中读出,否则「传输方」的写入操作会一直阻塞。




  • 第三步,在「接收方」读取文件流并保存到本地


private final IOptions.Stub options = new IOptions.Stub() {
@Override
public void transactFileDescriptor(ParcelFileDescriptor pfd) {
Log.i(TAG, "transactFileDescriptor: " + Thread.currentThread().getName());
Log.i(TAG, "transactFileDescriptor: calling pid:" + Binder.getCallingPid() + " calling uid:" + Binder.getCallingUid());
File file = new File(getCacheDir(), "file.iso");
try (
ParcelFileDescriptor.AutoCloseInputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(pfd);
) {
file.delete();
file.createNewFile();
FileOutputStream stream = new FileOutputStream(file);
byte[] buffer = new byte[1024];
int len;
// 将inputStream中的数据写入到file中
while ((len = inputStream.read(buffer)) != -1) {
stream.write(buffer, 0, len);
}
stream.close();
pfd.close();
} catch (IOException e) {
e.printStackTrace();
}
}
};


  • 运行程序


在程序运行之前,需要将一个大型文件放置到client app的缓存目录下,用于测试。目录地址:data/data/com.example.client/cache。



注意:如果使用模拟器测试,模拟器的硬盘要预留 3.5GB * 2 的闲置空间。



将程序运行起来,可以发现,3.5GB 的 file.iso 顺利传输到了Server端。



大文件是可以传输了,那么使用这种方式会很耗费内存吗?我们继续在文件传输时,查看一下内存占用的情况,如下所示:



  • 传输方-Client,内存使用情况




  • 接收方-Server,内存使用情况



从Android Studio Profiler给出的内存取样数据可以看出,无论是传输方还是接收方的内存占用都非常的克制、平缓。


总结


在编写本文之前,我在掘金上还看到了另一篇文章:一道面试题:使用AIDL实现跨进程传输一个2M大小的文件 - 掘金


该文章与本文类似,都是使用AIDL向接收端传输ParcelFileDescriptor,不过该文中使用共享内存MemoryFile构造出ParcelFileDescriptor,MemoryFile的创建需要使用反射,对于使用MemoryFile映射超大型文件是否会导致内存占用过大的问题,我个人没有尝试,欢迎有兴趣的朋友进行实践。


总得来说 ParcelFileDescriptor 和 MemoryFile 的区别有以下几点:



  • ParcelFileDescriptor 是一个封装了文件描述符的类,可以通过 Binder 传递给其他进程,实现跨进程访问文件或网络套接字。MemoryFile 是一个封装了匿名共享内存的类,可以通过反射获取其文件描述符,然后通过 Binder 传递给其他进程,实现跨进程访问共享内存。

  • ParcelFileDescriptor 可以用来打开任意的文件或网络套接字,而 MemoryFile 只能用来创建固定大小的共享内存。

  • ParcelFileDescriptor 可以通过 ParcelFileDescriptor.createPipe() 方法创建一对 ParcelFileDescriptor 对象,分别用于读写管道中的数据,实现进程间的数据流传输。MemoryFile 没有这样的方法,但可以通过 MemoryFile.getInputStream() 和 MemoryFile.getOutputStream() 方法获取输入输出流,实现进程内的数据流传输。


在其他领域的应用方面,ParcelFileDescriptor 和 MemoryFile也有着性能上的差异,主要取决于两个方面:



  • 数据的大小和类型。


如果数据是大型的文件或网络套接字,那么使用 ParcelFileDescriptor 可能更合适,因为它可以直接传递文件描述符,而不需要复制数据。如果数据是小型的内存块,那么使用 MemoryFile 可能更合适,因为它可以直接映射到物理内存,而不需要打开文件或网络套接字。



  • 数据的访问方式。


如果数据是需要频繁读写的,那么使用 MemoryFile 可能更合适,因为它可以提供输入输出流,实现进程内的数据流传输。如果数据是只需要一次性读取的,那么使用 ParcelFileDescriptor 可能更合适,因为它可以通过 ParcelFileDescriptor.createPipe() 方法创建一对 ParcelFileDescriptor 对象,分别用于读写管道中的数据,实现进程间的数据流传输。


本文示例demo的地址:github.com/linxu-link/…


好了,以上就是本文的所有内容了,感谢你的阅读,希望对你有所帮助。


作者:林栩link
来源:juejin.cn/post/7218615271384088633
收起阅读 »

使用 promise 重构 Android 异步代码

背景 业务当中写Android异步任务一直是一项挑战,以往的回调和线程管理方式比较复杂和繁琐,造成代码难以维护和阅读。在前端领域中JavaScript其实也面临同样的问题,Promise 就是它的比较主流的一种解法。 在尝试使用Promise之前我们也针对An...
继续阅读 »

背景


业务当中写Android异步任务一直是一项挑战,以往的回调和线程管理方式比较复杂和繁琐,造成代码难以维护和阅读。在前端领域中JavaScript其实也面临同样的问题,Promise 就是它的比较主流的一种解法。 在尝试使用Promise之前我们也针对Android现有的一些异步做了详细的对比。


文章思维导图


image.png


What:什么是Promise?


对于Android开发的同学,可能很多人不太熟悉Promise,它主要是前端的实践,所以先解析概念。
Promise 是 JavaScript 语言提供的一种标准化的异步管理方式,它的总体思想是,需要进行 io、等待或者其它异步操作的函数,不返回真实结果,而返回一个“承诺”,函数的调用方可以在合适的时机,选择等待这个承诺兑现(通过 Promise 的 then 方法的回调)。


最简单例子(JavaScript)


const promise = new Promise(function(resolve, reject) {
// ... some code

if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
}).then(function(value) {
console.log('resolved.');
}).catch(function(error) {
console.log('发生错误!', error);
});


实例化一个Promise对象,构造函数接受一个函数作为参数,该参数分别是resolvereject


resolve函数:将Promise 对象状态从pending 变成 resolved


reject函数:将Promise 对象状态从 pending 变成 rejected


then函数:回调 resolved状态的结果
catch函数:回调 rejected状态的结果



可以看到Promise的状态是非常简单且清晰的,这也让它在实现异步编程减少很多认知负担。


Why:为什么要考虑引入Promise


前面说的Promise 不就是 JavaScript 异步编程的一种思想吗,那这跟 Android 开发有什么关系? 虽然前端终端领域有所不同,但面临的问题其实是大同小异的,比如常见的异步回调导致回调地狱,逻辑处理不连贯等问题。
从事Android开发的同学应该对以下异步编程场景比较熟悉:



  • 单个网络请求

  • 多个网络请求竞速

  • 等待多个异步任务返回结果

  • 异步任务回调

  • 超时处理

  • 定时轮询


这里可以停顿思考一下,如果利用 Android常规的方式去实现以上场景,你会怎么做?你的脑子可能有以下解决方案:



  • 使用 Thread 创建

  • 使用 Thread + Looper + Handler

  • 使用 Android 原生 AsyncTask

  • 使用 HandlerThread

  • 使用 IntentService

  • 使用 线程池

  • 使用 RxJava 框架


以上方案都能在Android中实现异步任务处理,但或多或少存在一些问题和适用场景,我们详细剖析下各自的优缺点:


image.png


通过不同的异步实现方式的对比,可以发现每种实现方式都有适用场景,我们面对业务复杂度也是不一样的,每一种解决方案都是为了降低业务复杂度,用更低成本的方式来编码,但我们也知道代码写出来是给人看的,是需要持续迭代和维护,类似RxJava 这种框架于我们而言太复杂了,繁琐的操作符容易写出不易维护的代码,简单易理解应该是更好的追求,而不是炫技,所以我们才会探索用更轻量更简洁的编码方式来提升团队的代码一致性,就目前而言使用 Promise 来写代码将会有以下好处:



  • 解决回调地狱:Promise 可以把一层层嵌套的 callback 变成  .then().then()... ,从而使代码编写和阅读更直观

  • 易于处理错误:Promise 比 callback 在错误处理上更清晰直观

  • 非常容易编写多个异步操作的代码


How:怎么使用 Promise 重构业务代码?


这里由于我们的Java版本的Promise组件未开源,所以本部分只分析重构Case使用案例。


重构case1: 如何实现一个带超时的网络接口请求?


这是一段未重构前的获取付款码的异步代码:




可以看到以上代码存在以下问题:



  • 需要定义异步回调接口

  • 很多 if-else 判断,圈复杂度较高

  • 业务实现了一个超时类,为了不受网络库默认超时影响

  • 逻辑不够连贯,不易于维护


使用 Promise重构后:




可以看到有以下变化:



  • 消除了异步回调接口,链式调用让逻辑更连贯更清晰了

  • 通过 Promise 包装了网络请求调用,统一返回 Promise

  • 指定了 Promise 超时时间,无需额外实现繁琐的超时逻辑

  • 通过 validate 方法 替代 if - else 的判断,如果需要还可以定义校验规则

  • 统一处理异常错误,逻辑变得更加完备


重构case2:如何更优雅的实现长链接降级短链接?


重构前的做法:


代码存在以下问题:



  • 处理长链接请求超时,通过回调再处理降级逻辑

  • 使用Handler实现定时器轮询请求异步结果并处理回调

  • 处理各种逻辑判断,代码难以维护

  • 不易于模拟超时降级,代码可测试性差


使用Promise重构后:




第一个Promise处理长链接Push监听 ,设置5s超时,超时异常发生回调except方法,判断throwable 类型,如果为PromiseTimeoutException实例对象,则执行降级短链接。短链接是另外一个Promise,通过这种方式将逻辑都完全结果,代码不会割裂,逻辑更连贯。
短链接轮训查单逻辑使用Promise实现:




  • 最外层Promise,控制整体的超时,即不管轮询的结果如何,超过限定时间直接给定失败结果

  • Promise.delay(),这个比较细节,我们认定500ms轮询一定不会返回结果,则通过延迟的方式来减少一次轮询请求

  • Promise.retry(),真正重试的逻辑,限定了最多重试次数和延时逻辑,RetryStrategy定义的是重试的策略,延迟(delay)多少和满足怎样的条件(condition)才允许重试


这段代码把复杂的延时、条件判断、重试策略都通过Promise这个框架实现了,少了很多临时变量,代码量更少,逻辑更清晰。


重构case3:实现 iLink Push支付消息和短链接轮训查单竞速


后面针对降级策略重构成竞速模型,采用Promise.any很轻松得实现代码重构,代码如下图所示。



总结


本文提供一种异步编程的思路,借鉴了Promise思想来重构了Android的异步代码。通过Promise组件提供的多种并发模型能够更优雅的解决绝大部分的场景需求。


防踩坑指南


如果跟Activity或Fragment生命周期绑定,需要在生命周期结束时,取消掉promise的线程运行,否则可能会有内存泄露;这里可以采用AbortController来实现更优雅的中断 Promise。


并发模型



● 多任务并行请求


Promise.all():接受任意个Promise对象,并发执行异步任务。全部任务成功,有一个失败则视为整体失败。


Promise.allSettled(): 任务优先,所有任务必须执行完毕,永远不会进入失败状态。


Promise.any():接受任意个Promise对象,并发执行异步任务。等待其中一个成功即为成功,全部任务失败则进入错误状态,输出错误列表。


● 多任务竞速场景


Promise.race(): 接受任意个Promise对象,并发执行异步任务。时间是第一优先级,多个任务以最先返回的那个结果为准,此结果成功即为整体成功,失败则为整体失败。



扩展思考



  1. Promise 最佳实践




  1. 避免过长的链式调用:虽然Promise可以通过链式调用来避免回调地狱,但是如果Promise的链过长,代码的可读性和维护性也会变差。

  2. 及时针对Promise进行abort操作:Promise使用不当可能会造成内存泄露,比如未调用abort,页面取消未及时销毁proimse。

  3. 需要处理except异常回调,处理PromiseException.

  4. 可以使用validation来实现规则校验,减少if-else的规则判断




  1. Java Promise 组件实现原理




  1. 状态机实现(pending、fulfilled、rejected)

  2. 默认使用 ForkJoinPool 线程池,适合计算密集型任务。针对阻塞IO类型,可以使用内置ThreadPerTaskExecutor 简单线程池模型。




  1. Promise vs Kotlin协程



Promise 链式调用,代码清晰,上手成本较低;底层实现仍然是线程,通过线程池管理线程调度
Koitlin 协程,更轻量的线程,使用比较灵活,可以由开发者控制,比如挂起和恢复




  1. 可测试性的思考



根据 Promise 的特点,可以通过Mock状态(resolve、reject、outTime)来实现模拟成功,拒绝、超时;
实现思路:
● 自定义注解类辅助定位Hook点
● 使用ASM字节码对Promise 进行代码插桩



附录


Promise - JavaScript | MDN


Promises/A+


欢迎关注我的公众号,一起进步~


qrcode_for_gh_f3c52aa46d49_430 (1).jpg


作者:巫山老妖
来源:juejin.cn/post/7298955315621789730
收起阅读 »

Android电量优化,让你的手机续航更持久

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情...
继续阅读 »

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情发生,我们就要想想办法让我们的应用不那么耗电,电都用在该用的时候和地方。


通过power_profile.xml查看各个手机硬件的耗电量


Google要求手机硬件生产商都要放入power_profile.xml文件到ROM里面。有些不太负责的手机生产商,就乱配,也没有真正测试过。但我们还是可以大概知道耗电的硬件都有哪些。


先从ibotpeaches.github.io/Apktool/ 下载apktool反编译工具,然后执行adb命令,将手机framework的资源apk拉取出来。


adb pull /system/framework/framework-res.apk ./

然后我们用下载好的反编译工具,将framework-res.apk进行反编译。


java -jar apktool_2.7.0.jar d framework-res.apk

apktool_2.7.0.jar换成你下载的具体的jar包名称。
power_profile.xml文件的目录如下:



framework-res/res/xml/power_profile.xml



<?xml version="1.0" encoding="utf-8"?>
<device name="Android">
<item name="ambient.on">0.1</item>
<item name="screen.on">0.1</item>
<item name="screen.full">0.1</item>
<item name="bluetooth.active">0.1</item>
<item name="bluetooth.on">0.1</item>
<item name="wifi.on">0.1</item>
<item name="wifi.active">0.1</item>
<item name="wifi.scan">0.1</item>
<item name="audio">0.1</item>
<item name="video">0.1</item>
<item name="camera.flashlight">0.1</item>
<item name="camera.avg">0.1</item>
<item name="gps.on">0.1</item>
<item name="radio.active">0.1</item>
<item name="radio.scanning">0.1</item>
<array name="radio.on">
<value>0.2</value>
<value>0.1</value>
</array>
<array name="cpu.active">
<value>0.1</value>
</array>
<array name="cpu.clusters.cores">
<value>1</value>
</array>
<array name="cpu.speeds.cluster0">
<value>400000</value>
</array>
<array name="cpu.active.cluster0">
<value>0.1</value>
</array>
<item name="cpu.idle">0.1</item>
<array name="memory.bandwidths">
<value>22.7</value>
</array>
<item name="battery.capacity">1000</item>
<item name="wifi.controller.idle">0</item>
<item name="wifi.controller.rx">0</item>
<item name="wifi.controller.tx">0</item>
<array name="wifi.controller.tx_levels" />
<item name="wifi.controller.voltage">0</item>
<array name="wifi.batchedscan">
<value>.0002</value>
<value>.002</value>
<value>.02</value>
<value>.2</value>
<value>2</value>
</array>
<item name="modem.controller.sleep">0</item>
<item name="modem.controller.idle">0</item>
<item name="modem.controller.rx">0</item>
<array name="modem.controller.tx">
<value>0</value>
<value>0</value>
<value>0</value>
<value>0</value>
<value>0</value>
</array>
<item name="modem.controller.voltage">0</item>
<array name="gps.signalqualitybased">
<value>0</value>
<value>0</value>
</array>
<item name="gps.voltage">0</item>
</device>

抓到不负责任的手机生产商一枚,好家伙,这么多0.1,明眼人一看就知道这是为了应付Google。尽管这样,我们还是可以从中知道,耗电的有Screen(屏幕亮屏)、Bluetooth(蓝牙)、Wi-Fi(无线局域网)、Audio(音频播放)、Video(视频播放)、Radio(蜂窝数据网络)、Camera的Flashlight(相机闪光灯)和GPS(全球定位系统)等。


电量杀手简介


Screen


屏幕是非常耗电的一个硬件,不要问我为什么。屏幕主要有LCD和OLED两种。LCD屏幕白色光线从屏幕背后的灯管发出,尽管屏幕显示黑屏,依旧耗电,这种屏幕逐渐被淘汰,如果你翻出个早点的功能机,或许能看到。那么大部分Android手机都是OLED的屏幕,每个像素点都是独立的发光单元,屏幕黑屏时,所有像素都不发光。有必要时,让屏幕息屏很重要,当然手机也有自动息屏的时间设置,这个不太需要我们操心。


Radio数据网络和Wi-Fi无线网络


网络也是非常耗电的,其中又以数据网络的耗电更多于Wi-Fi的耗电。所以请尽量引导用户使用Wi-Fi网络使用app的部分功能,比如下载文件。


GPS


GPS也是很耗电的硬件,所以不要动不动就请求地理位置,GPS平常是要关闭的,除非你在使用定位和导航等功能,这样你的手机续航会更好。


WakeLock


如果使用了WakeLock,是可以有效防止息屏情况下的CPU休眠,但是如果不用了,你不释放掉锁的话,则会带来很大的电量的开销。


查看手机耗电的历史记录


// 上次拔掉电源到现在的耗电情况
adb shell dumpsys batterystats --unplugged

你在逗我?让我看命令行的输出?后面我们来使用Battery Historian的图表进行分析。


使用Battery Historian分析手机耗电量


安装Docker


Docker下载网址 docs.docker.com/desktop/ins…


使用Docker容器编排


docker run -p 9999:9999 gcr.io/android-battery-historian/stable:3.0 --port 9999

获取bugreport文件


Android7.0及以上


adb bugreport bugreport.zip

Android6.0及以下


adb bugreport > bugreport.txt

上传bugreport文件进行分析


在浏览器地址栏输入http://localhost:9999
截屏2023-02-05 05.39.12.png
点击Browse按钮并上传bugreport.zip或bugreport.txt生成分析图表。
截屏2023-02-05 05.44.59.png
我们可以通过时间轴来分析应用当下的电池使用情况,比较耗电的是哪部分硬件。


使用JobScheduler来合理执行后台任务


JobScheduler是Android5.0版本推出的API,允许开发者在符合某些条件时创建执行在后台的任务。比如接通电源的情况下才执行某些耗电量大的操作,也可以把一些不紧急的任务在合适的时候批量处理,还可以避开低电量的情况下执行某些任务。


作者:dora
来源:juejin.cn/post/7196321890301575226
收起阅读 »