注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

能说一说 Kotlin 中 lateinit 和 lazy 的区别吗?

使用 Kotlin 进行开发,对于 latelinit 和 lazy 肯定不陌生。但其原理上的区别,可能鲜少了解过,借着本篇文章普及下这方面的知识。 lateinit 用法 非空类型可以使用 lateinit 关键字达到延迟初始化。  class I...
继续阅读 »

使用 Kotlin 进行开发,对于 latelinit 和 lazy 肯定不陌生。但其原理上的区别,可能鲜少了解过,借着本篇文章普及下这方面的知识。


lateinit


用法


非空类型可以使用 lateinit 关键字达到延迟初始化。


 class InitTest() {
     lateinit var name: String
 
     public fun checkName(): Boolean = name.isNotEmpty()
 }

如果在使用前没有初始化的话会发生如下 Exception。


 AndroidRuntime: FATAL EXCEPTION: main
      Caused by: kotlin.UninitializedPropertyAccessException: lateinit property name has not been initialized
        at com.example.tiramisu_demo.kotlin.InitTest.getName(InitTest.kt:4)
        at com.example.tiramisu_demo.kotlin.InitTest.checkName(InitTest.kt:10)
        at com.example.tiramisu_demo.MainActivity.testInit(MainActivity.kt:365)
        at com.example.tiramisu_demo.MainActivity.onButtonClick(MainActivity.kt:371)
        ...

为防止上述的 Exception,可以在使用前通过 ::xxx.isInitialized 进行判断。


 class InitTest() {
     lateinit var name: String
 
     fun checkName(): Boolean {
         return if (::name.isInitialized) {
             name.isNotEmpty()
        } else {
             false
        }
    }
 }

 Init: testInit():false

当 name 初始化过之后使用亦可正常。


 class InitTest() {
     lateinit var name: String
 
     fun injectName(name: String) {
         this.name = name
    }
 
     fun checkName(): Boolean {
         return if (::name.isInitialized) {
             name.isNotEmpty()
        } else {
             false
        }
    }
 }

 Init: testInit():true

原理


反编译之后可以看到该变量没有 @NotNull 注解,使用的时候要 check 是否为 null。


 public final class InitTest {
    public String name;
       
    @NotNull
    public final String getName() {
       String var10000 = this.name;
       if (var10000 == null) {
          Intrinsics.throwUninitializedPropertyAccessException("name");
      }
 
       return var10000;
    }
 
     public final boolean checkName() {
       String var10000 = this.name;
       if (var10000 == null) {
          Intrinsics.throwUninitializedPropertyAccessException("name");
      }
 
       CharSequence var1 = (CharSequence)var10000;
       return var1.length() > 0;
    }
 }

null 则抛出对应的 UninitializedPropertyAccessException。


 public class Intrinsics {
  public static void throwUninitializedPropertyAccessException(String propertyName) {
         throwUninitializedProperty("lateinit property " + propertyName + " has not been initialized");
    }
 
  public static void throwUninitializedProperty(String message) {
         throw sanitizeStackTrace(new UninitializedPropertyAccessException(message));
    }
 
  private static <T extends Throwable> T sanitizeStackTrace(T throwable) {
         return sanitizeStackTrace(throwable, Intrinsics.class.getName());
    }
 
     static <T extends Throwable> T sanitizeStackTrace(T throwable, String classNameToDrop) {
         StackTraceElement[] stackTrace = throwable.getStackTrace();
         int size = stackTrace.length;
 
         int lastIntrinsic = -1;
         for (int i = 0; i < size; i++) {
             if (classNameToDrop.equals(stackTrace[i].getClassName())) {
                 lastIntrinsic = i;
            }
        }
 
         StackTraceElement[] newStackTrace = Arrays.copyOfRange(stackTrace, lastIntrinsic + 1, size);
         throwable.setStackTrace(newStackTrace);
         return throwable;
    }
 }
 
 public actual class UninitializedPropertyAccessException : RuntimeException {
    ...
 }

如果是变量是不加 lateinit 的非空类型,定义的时候即需要初始化。


 class InitTest() {
     val name: String = "test"
 
     public fun checkName(): Boolean = name.isNotEmpty()
 }

在反编译之后发现变量多了 @NotNull 注解,可直接使用。


 public final class InitTest {
    @NotNull
    private String name = "test";
 
    @NotNull
    public final String getName() {
       return this.name;
    }
 
    public final boolean checkName() {
       CharSequence var1 = (CharSequence)this.name;
       return var1.length() > 0;
    }
 }

::xxx.isInitialized 的话进行反编译之后可以发现就是在使用前进行了 null 检查,为空直接执行预设逻辑,反之才进行变量的使用。


 public final class InitTest {
    public String name;
    ...
    public final boolean checkName() {
       boolean var2;
       if (((InitTest)this).name != null) {
          String var10000 = this.name;
          if (var10000 == null) {
             Intrinsics.throwUninitializedPropertyAccessException("name");
          }
 
          CharSequence var1 = (CharSequence)var10000;
          var2 = var1.length() > 0;
      } else {
          var2 = false;
      }
 
       return var2;
    }
 }

lazy


用法


lazy 的命名和 lateinit 类似,但使用场景不同。其是用于懒加载,即初始化方式已确定,只是在使用的时候执行。而且修饰的只是能是 val 常量。


 class InitTest {
     val name by lazy {
         "test"
    }
     
     public fun checkName(): Boolean = name.isNotEmpty()
 }

lazy 修饰的变量可以直接使用,不用担心 NPE。


 Init: testInit():true

原理


上述是 lazy 最常见的用法,反编译之后的代码如下:


 public final class InitTest {
    @NotNull
    private final Lazy name$delegate;
 
    @NotNull
    public final String getName() {
       Lazy var1 = this.name$delegate;
       return (String)var1.getValue();
    }
 
    public final boolean checkName() {
       CharSequence var1 = (CharSequence)this.getName();
       return var1.length() > 0;
    }
 
    public InitTest() {
       this.name$delegate = LazyKt.lazy((Function0)null.INSTANCE);
    }
 }

所属 class 创建实例的时候,实际分配给 lazy 变量的是 Lazy 接口类型,并非 T 类型,变量会在 Lazy 中以 value 暂存,当使用该变量的时候会获取 Lazy 的 value 属性。


Lazy 接口的默认 mode 是 LazyThreadSafetyMode.SYNCHRONIZED,其默认实现是 SynchronizedLazyImpl,该实现中 _value 属性为实际的值,用 volatile 修饰。


value 则通过 get() 从 _value 中读写,get() 将先检查 _value 是否尚未初始化




  • 已经初始化过的话,转换为 T 类型后返回




  • 反之,执行同步方法(默认情况下 lock 对象为 impl 实例),并再次检查是否已经初始化:



    • 已经初始化过的话,转换为 T 类型后返回

    • 反之,执行用于初始化的函数 initializer,其返回值存放在 _value 中,并返回




 public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
 
 private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
     private var initializer: (() -> T)? = initializer
     @Volatile private var _value: Any? = UNINITIALIZED_VALUE
     // final field is required to enable safe publication of constructed instance
     private val lock = lock ?: this
 
     override val value: T
         get() {
             val _v1 = _value
             if (_v1 !== UNINITIALIZED_VALUE) {
                 @Suppress("UNCHECKED_CAST")
                 return _v1 as T
            }
 
             return synchronized(lock) {
                 val _v2 = _value
                 if (_v2 !== UNINITIALIZED_VALUE) {
                     @Suppress("UNCHECKED_CAST") (_v2 as T)
                } else {
                     val typedValue = initializer!!()
                     _value = typedValue
                     initializer = null
                     typedValue
                }
            }
        }
 
     override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE
 
     override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."
 
     private fun writeReplace(): Any = InitializedLazyImpl(value)
 }

总之跟 Java 里双重检查懒汉模式获取单例的写法非常类似。


 public class Singleton {
     private static volatile Singleton singleton;
 
     private Singleton() {
    }
 
     public static Singleton getInstance() {
         if (singleton == null) {
             synchronized (Singleton.class) {
                 if (singleton == null) {
                     singleton = new Singleton();
                }
            }
        }
         return singleton;
    }
 }

lazy 在上述默认的 SYNCHRONIZED mode 下还可以指定内部同步的 lock 对象。


     val name by lazy(lock) {
         "test"
    }

lazy 还可以指定其他 mode,比如 PUBLICATION,内部采用不同于 synchronizedCAS 机制。


     val name by lazy(LazyThreadSafetyMode.PUBLICATION) {
         "test"
    }

lazy 还可以指定 NONE mode,线程不安全。


     val name by lazy(LazyThreadSafetyMode.NONE) {
         "test"
    }

the end


lateinit 和 lazy 都是用于初始化场景,用法和原理有些区别,做个简单总结:


lateinit 用作非空类型的初始化:



  • 在使用前需要初始化

  • 如果使用时没有初始化内部会抛出 UninitializedPropertyAccess Exception

  • 可配合 isInitialized 在使用前进行检查


lazy 用作变量的延迟初始化:



  • 定义的时候已经明确了 initializer 函数体

  • 使用的时候才进行初始化,内部默认通过同步锁和双重校验的方式返回持有的实例

  • 还支持设置 lock 对象和其他实现 mode

作者:TechMerger
链接:https://juejin.cn/post/7124114861260275742
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter 桌面探索 | 自定义可拖拽导航栏

1. 前言 上一篇 《桌面导航 NavigationRail》 中介绍了官方的桌面导航,但整体灵活性并不是太好,风格我也不是很喜欢。看到飞书桌面端的导航栏可以支持拖拽排序,感觉挺有意思。而且排序之后,下次进入时会使用该顺序,而且在其他设备上也会同步该配置顺序。...
继续阅读 »
1. 前言

上一篇 《桌面导航 NavigationRail》 中介绍了官方的桌面导航,但整体灵活性并不是太好,风格我也不是很喜欢。看到飞书桌面端的导航栏可以支持拖拽排序,感觉挺有意思。而且排序之后,下次进入时会使用该顺序,而且在其他设备上也会同步该配置顺序。这说明用户登录时会从服务器获取配置信息,作为导航栏的状态数据决定显示。



本文我们将来探讨两个问题:



  • 第一:如何将导航栏的数据变得 可配置

  • 第二:如何实现 拖拽 更改导航栏位置。




2.整体静态界面布局:

首先,我们先来对整体结构进行一下静态布局,也就是先抛开交互逻辑,对整体结构进行一下划分。整体是一个 上下 结构,下方是 导航栏 + 内容 的左右结构:



下面是对静态界面结构的简单仿写,本文主要介绍导航栏的交互实现,其他内容暂时忽略。以后有机会可以慢慢展开来说。





代码如下,整体界面的呈现由 AppNavigation 负责。通过 Column 实现上下结构,上面是 TopBar ,下面是通过 Expanded 包裹,可以让内容填充剩余部分。下方通过 Row 实现左右结构,左侧是今天的主角 LeftNavigationBar 组件,右侧是一个暂时空白的内容。


class AppNavigation extends StatelessWidget {
const AppNavigation({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
const TopBar(),
Expanded(
child: Row(
children: const [
LeftNavigationBar(),
// TODO 主题内容构建
Expanded(child: SizedBox.shrink()),
],
))
],
),
);
}
}

所以整体结构还是很简单的,通过 Expanded 组件,可以让指定的区域具有 “延展性” 。比如下面,当窗口尺寸变化时,中间的区域会自动收缩,而头部栏和导航栏不会受到影响。





3. 导航栏布局实现

导航栏是自定义的 LeftNavigationBar 组件,是一个上下结构:Logo 在最底端,LeftNavigationMenu 菜单在上方。这里的 Spacer 相当于一个占位组件,其高度为 Column 的剩余部分,也就是会 “撑开” 区域,在窗口高度发生变化时,这块区域会自动延展,来保证 Logo 始终在下方。





界面上呈现的内容,都有其对应的数据载体。这里先简单定义一个 LeftNavigationBarItem 的实体类,用于记录图标和标题信息。另外 id 用于菜单的唯一标识,因为后面要涉及到菜单位置的交换,不能靠索引进行标识:


class LeftNavigationBarItem {
final int id;
final IconData icon;
final String label;

const LeftNavigationBarItem({
required this.icon,
required this.label,
required this.id,
});
}



LeftNavigationMenu 组件中接收 LeftNavigationBarItem 列表数据。通过 Column 组件进行竖直排布,另外把每个菜单的单体抽离为 LeftNavigationBarItemWidget 组件方便维护:


class LeftNavigationMenu extends StatelessWidget {
final List<LeftNavigationBarItem> items;

const LeftNavigationMenu({Key? key, required this.items}) : super(key: key);

@override
Widget build(BuildContext context) {
return Column(
children: items
.map((e) => LeftNavigationBarItemWidget( item: e ))
.toList(),
);
}
}



对于导航栏而言,鼠标悬浮一般会有一个临时的激活状态。外界并不需要用到这个状态,所以可以将 LeftNavigationBarItemWidget 组件定义为 StatefulWidget ,来维护悬浮时的内部状态变化。



如下,在单体的组件状态类中定义 _hovering 私有状态量,通过 InkWell 监听悬浮的变化。由于这里是单独抽离的 LeftNavigationBarItemWidget 组件,所以这里在 _onHover 中触发的 setState 只会对局部组件进行构建。在构建时,根据 active 状态创建不同样式的条目即可。





4. 菜单的点击激活状态管理

界面上呈现的内容,都有其对应的数据载体,菜单的点击激活也不例外。比如你在飞书中点击了一个菜单,变成激活态,就表示在内存中一定对某个菜单的激活数据信息进行了变动,并重新渲染。我们想实现点击更换激活菜单,也是一样。需要考虑的只有两件事:



  • 如何 记录维护 数据的变化。

  • 如何在数据变化后触发更新。


状态管理的工具多种多样,但都不会脱离这两件本质的工作,不同的只是用法的形式而已。不必为了一些表面的功夫争论不休,而忽略问题的本质,适合自己就是好的。其实 State 类本身也是一种状态管理的工具,也有维护数据变化和触发更新的特定性,只不过处理较深层级间的共享数据时比较麻烦。


关于这一点,在上次掘金直播中进行过介绍,感兴趣的可以去看一下 回放 。由于没有什么直播经验,所以那次显得很紧张,不过想分享的核心知识还是都介绍到的。




这里用我比较熟悉的 flutter_bloc 来对激活菜单数据进行管理。现在引入 Cubit 后,对于小的数据进行管理变得非常方便。比如下面的 NavSelectionCubic ,只用 4 行代码就能实现对 激活菜单 id 的管理:


class NavSelectionCubic extends Cubit<int> {
NavSelectionCubic({int id = 1}) : super(id);

void selectMenu(int id) {
emit(id);
}
}



上面完成了 记录维护 数据的变化,那接下来的重点就是:如何在数据变化后触发更新。通过 BlocBuilder 可以在变化到新状态时,触发 builder 回调,重新构建局部组件,实现局部刷新。





在点击菜单是,触发 NavSelectionCubicselectMenu 方法,更新状态数据即可。这样就可以实现如下效果:点击某个菜单,变为激活状态:



---->[_LeftNavigationBarItemWidgetState#_onTap]----
void _onTap() {
BlocProvider.of<NavSelectionCubic>(context).selectMenu(widget.item.id);
}



5. 菜单数据的状态管理

我们现在的菜单数据是写死的,对于可拖拽的功能,需要对这些数据进行修改和触发更新。所以菜单数据本身也就上升为了需要管理的状态。对菜单数据状态进行管理,还有个好处:可以动态的修改菜单,比如不同角色的显示不同的菜单,只要根据角色维护数据即可。





这里再定义一个 NavMenuCubic 用于管理菜单数据,状态量是 NavMenus ,其中维护着 LeftNavigationBarItem 的列表。这样在拖拽时,执行 switchMenu 方法,进行拖拽菜单数据交换,再产出新的状态,即可完成需求。


class NavMenuCubic extends Cubit<NavMenus> {
NavMenuCubic({required List<LeftNavigationBarItem> item}) : super(NavMenus(menus:item ));

void switchMenu(int dragId, int targetId) {
// TODO 处理拖拽菜单数据交换
}
}

class NavMenus{
final List<LeftNavigationBarItem> menus;

const NavMenus({required this.menus});

}



另外说一点,导航模块使用了两个 Bloc ,可以单独抽离一个组件进行包裹 BlocProvider,这样其子树的上下文中才可以访问到相关的 Bloc。比如下面的 _NavigationScope ,这里的菜单数据直接给出,其实也可以通过服务端记录这些配置数据,在登录时读取进行初始化:


class _NavigationScope extends StatelessWidget {
const _NavigationScope({Key? key}) : super(key: key);

final List<LeftNavigationBarItem> items = const [
LeftNavigationBarItem(id: 1, icon: Icons.message_outlined, label: "消息"),
LeftNavigationBarItem(id: 2, icon: Icons.video_camera_back_outlined, label: "视频会议"),
LeftNavigationBarItem(id: 3, icon: Icons.book_outlined, label: "通讯录"),
LeftNavigationBarItem(id: 4, icon: Icons.cloud_upload_outlined, label: "云文档"),
LeftNavigationBarItem(id: 5, icon: Icons.games_sharp, label: "工作台"),
LeftNavigationBarItem(id: 6, icon: Icons.calendar_month, label: "日历"),
];

@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<NavSelectionCubic>(
create: (BuildContext context) => NavSelectionCubic(id:items.first.id),
),
BlocProvider<NavMenuCubic>(
create: (BuildContext context) => NavMenuCubic(items:items),
),
],
child: const LeftNavigationBar(),
);
}
}



6. 如何拖动菜单

我们先来分析一下拖拽菜单的界面表现。如下所示,可将一个菜单拖拽出来,拖出的组件具有一定的透明度;另外当拖拽物达到目标时,目标底部会显示蓝线示意移至其下。这里使用的是 DraggableDragTarget 的组合,其中 Draggable 指的是可拖拽物体,DragTarget 指的是受体目标。

可以看出,其实这里导航菜单同时承担着这两种角色,既需要拖拽,又需要作为目标接收拖拽物,这就是可拖拽导航的一个小难点。另外还有一个小细节,在拖拽过程中要禁止 _hovering 的悬浮激活,结束后要开启悬浮激活。



下面是代码实现的核心,其中对应 _disableHover 标识来控制是否可以悬浮激活,在 DragTarget 的相关回调中维护 _disableHover 的值。 DraggableDragTarget 需要一个泛型,也就是拖拽交互中需要传递的数据,这里是 int 类型的菜单 id 。数据由 Draggable 提供,如下 tag1 处所示,交互过程中有两个组件,其一是随拖拽浮动的部分,由 buildDraggableChild 方法构建,其二是主体菜单组件,由 buildTargetChild 方法构建。


class _LeftNavigationBarItemWidgetState  extends State<LeftNavigationBarItemWidget> {
bool _hovering = false;
bool _disableHover = false;

void _onTap() {
BlocProvider.of<NavSelectionCubic>(context).selectMenu(widget.item.id);
}

void _onHover(bool value) {
if (_disableHover) return;
setState(() {
_hovering = value;
});
}

final Color color = const Color(0xffcfd1d7);
final Color activeColor = Colors.blue;

@override
Widget build(BuildContext context) {
return DragTarget<int>(
onAccept: _onAccept,
builder: _buildTarget,
onMove: _onMove,
onLeave: _onLeave,
onWillAccept: _onWillAccept,
);
}

Widget buildTargetChild(bool active, bool dragging, int? dragItemId) {
// 暂略...
}

Widget buildDraggableChild(bool active) {
// 暂略...
}

Widget _buildTarget(BuildContext context, List<int?> candidateData,
List<dynamic> rejectedData) {
bool active = widget.active || _hovering;
int? id;
if (candidateData.isNotEmpty) {
id = candidateData.first;
}
Widget child = buildTargetChild(active, _disableHover, id);

return Draggable<int>(
data: widget.item.id, // tag1
feedback: buildDraggableChild(widget.active), // tag2
child: child,
);
}

下面来单独看一下 DragTarget 的几个回调方法。_onWillAccept 可以通过返回值来控制,是否拖拽物是否符合目标的接收条件,只有符合条件才会在后续触发 _onAccept。比如这里当携带的 id 不是自身的 id 时,符合接收条件,这样就可以避免自己拖到自己身上的问题。

_onAccept 顾名思义,表示拖拽符合条件被接收,我们之后在此回调中对菜单栏进行重排序,再触发更新即可。_onMove 在拖拽物移入目标时触发,_onLeave在拖拽物离开目标时触发。另外 Draggable 中有一些拖拽事件相关的回调,在这里作用不大,大家可以只了解一下。


  bool _onWillAccept(int? data) {
print('=====_onWillAccept=======$data===${data != widget.item.id}===');
return data != widget.item.id;
}

void _onAccept(int data) {
print('=====_onAccept=======$data======');
_disableHover = false;
}

void _onMove(DragTargetDetails<int> details) {
_hovering = false;
_disableHover = true;
}

void _onLeave(int? data) {
print('=====_onLeave=============');
_disableHover = false;
}
}

最后看一下 buildTargetChild 中的一个小细节,也就是达到目标时,目标组件底部出现蓝色线条示意。 DragTarget 组件的构建组件的回调中,可以感知到携带的数据。如下,只要根据 id 数据进行校验,当 enable 时添加底部边线即可:





7. 拖拽更新菜单数据

上面把所有的准备工作都完成了,接下来想要拖拽更新菜单数据,也就能水到渠成。前面说过。菜单数据由 NavMenuCubic 维护,现在只要在 switchMenu 中完成业务逻辑,在 _onAccept 中触发即可。这样界面交互、数据变化、界面更新三个层次就会非常清晰。


class NavMenuCubic extends Cubit<NavMenus> {
NavMenuCubic({required List<LeftNavigationBarItem> items}) : super(NavMenus(menus:items ));

void switchMenu(int dragId, int targetId) {
// TODO 处理拖拽菜单数据交换
}
}



如下,是交换的处理逻辑,根据 dragIdtargetId 获取在列表中的索引,然后移除和添加而已。就是最基本的数据处理,在刚才的 _onAccept 方法中触发交换即可,效果如下:



---->[NavMenuCubic#switchMenu]---- 
void switchMenu(int dragId, int targetId) {
List<LeftNavigationBarItem> items = state.menus;
int dragIndex = 0;
int targetIndex = 0;
for(int i =0;i<items.length;i++){
LeftNavigationBarItem item = items[i];
if(item.id == dragId){
dragIndex = i;
}
}
LeftNavigationBarItem dragItem = items.removeAt(dragIndex);
for(int i =0;i<items.length;i++) {
LeftNavigationBarItem item = items[i];
if (item.id == targetId) {
targetIndex = i;
}
}
items.insert(targetIndex+1, dragItem);
print(items);
emit(NavMenus(menus: items));
}
}

---->[_LeftNavigationBarItemWidgetState]----
void _onAccept(int data) {
print('=====_onAccept=======$data======');
BlocProvider.of<NavMenuCubic>(context).switchMenu(data,widget.item.id);
_disableHover = false;
}



这里只是进行最基础的拖拽导航栏需求,还有一些可以拓展的地方。比如将菜单的数据存储在本地,这样就可以保证程序关闭之后,再打开不会重置。另外也可以提供相关的后端接口,让数据同步到服务端,这样多设备就可以实现同步。

本文简单介绍了一下状态管理的使用价值,完成了一个简单的自定义可拖拽导航栏,相信从中你可以学到一些东西。后续会基于这个导航继续拓展,比如界面切换,支持添加移除等。那本文就到这里,谢谢观看~


作者:张风捷特烈
链接:https://juejin.cn/post/7124510001212686367
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android AIDL使用指南

AIDL 全称 Android Interface Definition Language ,安卓接口定义语言。AIDL 用来解决 Android 的跨进程通信问题,底层原理是 Binder ,实现思路是 C / S 架构思想。Server:接收请求,提供处理...
继续阅读 »

AIDL 全称 Android Interface Definition Language ,安卓接口定义语言。

AIDL 用来解决 Android 的跨进程通信问题,底层原理是 Binder ,实现思路是 C / S 架构思想。

  • Server:接收请求,提供处理逻辑,并发送响应数据。
  • Client:发起请求,接收响应数据。

C / S 之间通过 Binder 对象进行通信。

  • Server 需要实现一个 Service 作为服务器,Client 侧则需要调用发起请求的能力。
  • Client 需要调用 bindService 绑定到远程服务,然后通过 ServiceConnection 来接收远程服务的 Binder 对象。拿到 Binder 对象后就可以调用远程服务中定义的方法了。

因为是跨进程通信,所以需要实现序列化,AIDL 专门为 Android 设计,所以它的序列化不能使用 Java 提供的 Serializable ,而是 Android 提供的 Parcelable 接口。

AIDL 的用法

以一个跨进程相互发送消息的 Demo 为例,演示 AIDL 的用法。

场景:两个 App ,一个作为 Server ,用来启动一个服务,接收另一个作为 Client 的 App 发来的请求(Request),然后并进行响应(Response),另外,Server 也可以主动发消息给绑定到 Server 的 Client 。

Step 1 定义通信协议

AIDL 解决的是远程通信问题,是一种 C / S 架构思想,参考网络通信模型,客户端和服务端之间,需要约定好彼此支持哪些东西,提供了什么能力给对方(这里主要是服务端提供给客户端的),而定义能力在面向对象的编程中,自然就是通过接口来定义。所以 AIDL 是 Interface Definition Language。

定义服务端暴露给客户端的能力,首先要创建 AIDL 文件。AIDL 文件在同包名下,与 java / res 等目录同级,创建 aidl 文件夹,其内部结构和 java 保持一致:

src
|- java
|-- com
|--- chunyu
|---- aidl
|----- service
|------ ...// java file

|- aidl
|-- com
|--- chunyu
|---- aidl
|----- service
|------ ... // aidl file

|- ...
复制代码

在定义 AIDL 接口前,我们现实现一个数据类,这个数据类作为服务端和客户端之间通信的数据结构。如果你的通信不需要复杂的数据对象,而是 int 、 long 等基本数据类型和 String ,则不需要这一个步骤。

通过 Android Studio 右键创建 AIDL 文件时,默认会生成一个方法:

interface IServerManager {
/**
* 演示了一些可以在AIDL中用作参数和返回值的基本类型。
*/
void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat,
double aDouble, String aString);
}
复制代码

这里说明了除了要实现 Parcelable 序列化接口的对象,这些类型可以直接传递。

这里我们定义一个 Msg 类,作为通信传递的数据类型,在 java 目录下实现这个类:

class Msg(var msg: String, var time: Long? = null): Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString() ?: "",
parcel.readValue(Long::class.java.classLoader) as? Long
) {
}

override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(msg)
parcel.writeValue(time)
}

override fun describeContents(): Int {
return 0
}

companion object CREATOR : Creator<Msg> {
override fun createFromParcel(parcel: Parcel): Msg {
return Msg(parcel)
}

override fun newArray(size: Int): Array<Msg?> {
return arrayOfNulls(size)
}
}
}
复制代码

其实只需要定义参数即可,方法和成员通过快捷键会自动实现。实现完这个类后,需要在 aidl 同包名下创建一个 Msg.aidl 文件,将这个类型进行声明:

package com.chunyu.aidl.service;

parcelable Msg;
复制代码

数据对象定义好了,下一步就可以定义服务端暴露给客户端的接口了。

首先定义一个服务端主动回调给客户端的接口,所有注册了的 Client ,都可以接收到服务端的主动消息:

package com.chunyu.aidl.service;

import com.chunyu.aidl.service.Msg;

interface IReceiveMsgListener {
void onReceive(in Msg msg);
}
复制代码

需要注意的一点是,AIDL 文件中的 import 并不会自动导入,需要开发者自行添加。

然后定义 Server 暴露给 Client 的能力:

package com.chunyu.aidl.service;

import com.chunyu.aidl.service.Msg;
import com.chunyu.aidl.service.IReceiveMsgListener;

interface IMsgManager {
// 发消息
void sendMsg(in Msg msg);
// 客户端注册监听回调
void registerReceiveListener(IReceiveMsgListener listener);
// 客户端取消监听回调
void unregisterReceiveListener(IReceiveMsgListener listener);
}
复制代码

IMsgManager 提供了三个方法,用来解决两个场景:

  • Client 主动发送 Msg 给 Server (sendMsg 方法,也可以理解为网络通信中的客户端发起请求)。
  • Server 主动发消息给所有订阅者,通过回调 IReceiveMsgListener 中的 onReceive 方法,每个注册的 Client 都会收到回调(典型的观察者模式)。

所有关于 AIDL 的部分就到这里了,此时,开发者需要手动运行 build 重新构建项目,这样 AIDL 会在 build 后生成一些 class 文件,供项目代码中调用。

注意:每次 AIDL 的改动都需要手动 build 一下。

Step 2 定义服务端

服务端的定义需要创建一个 Service 来作为服务器。这里创建一个 MyService 类,然后在 AndroidManifest.xml 中配置一个 action ,这个 action 后续会用来进行跨进程启动:

        <service
android:name=".MyService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="com.chunyu.aidl.service.MyService"></action>
</intent-filter>
</service>
复制代码

配置完这个后,就可以在 MyService 中实现服务器的逻辑了。

Service 的启动方式有两种,startService 和 bindService ,后者是我们在实现 Client 连接 Server 的核心方法。通过 bindService 创建 C / S 之间的连接。而 Service 在通过 bindService 启动时,会回调 onBind 方法:

fun onBind(intent: Intent): IBinder 
复制代码

onBind 的返回类型时 IBinder ,这个就是跨进程通信间传递的 Binder 。在同一个进程中不需要进行跨进程通信,这里可以返回为 null 。而此时需要实现 IPC ,自然这个方法需要返回一个存在的 Binder 对象,所以,配置服务器的第二步,就是实现一个 Binder 并在 onBind 中返回。

我们在定义通信协议时,定义了一个用来表示 Server 提供给 Client 能力的接口 IMsgManager,经过 build 后,编译器会自动生成 IMsgManager.Stub ,这是一个自动生成的实现了 IMsgManager 接口的 Binder 抽象实现:

public static abstract class Stub extends android.os.Binder implements com.chunyu.aidl.service.IMsgManager
复制代码

在 MyService 中实现这个抽象类:

class MyService : Service() {

// ...

inner class MyBinder: IMsgManager.Stub() {
override fun sendMsg(msg: Msg?) {
// todo 收到 Client 发来的消息,此处实现 Server 的处理逻辑
}
override fun sendMsg(msg: Msg?) {
// todo 收到 Client 发来的消息,此处实现 Server 的处理逻辑
val n = receiveListeners.beginBroadcast()
for (i in 0 until n) {
val listener = receiveListeners.getBroadcastItem(i)
listener?.let {
try {
val serverMsg = Msg("服务器响应 ${Date(System.currentTimeMillis())}\n ${packageName}", System.currentTimeMillis())
listener.onReceive(serverMsg)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
}
receiveListeners.finishBroadcast()
}

override fun registerReceiveListener(listener: IReceiveMsgListener?) {
// receiveListeners 记录观察者
receiveListeners.register(listener)
}

override fun unregisterReceiveListener(listener: IReceiveMsgListener?) {
val success = receiveListeners.unregister(listener)
if (success) {
Log.d(TAG, "解除注册成功")
} else {
Log.d(TAG, "解除注册失败")
}
}
}
}
复制代码

然后,在 onBind 方法中返回这个类的对象:

    override fun onBind(intent: Intent): IBinder {
return MyBinder()
}
复制代码

整体的一个服务端代码:

class MyService : Service() {

private val receiveListeners = RemoteCallbackList<IReceiveMsgListener>()

override fun onBind(intent: Intent): IBinder {
return MyBinder()
}

inner class MyBinder: IMsgManager.Stub() {
override fun sendMsg(msg: Msg?) {
// server process request at here
}

override fun registerReceiveListener(listener: IReceiveMsgListener?) {
receiveListeners.register(listener)
}

override fun unregisterReceiveListener(listener: IReceiveMsgListener?) {
val success = receiveListeners.unregister(listener)
if (success) {
Log.d(TAG, "解除注册成功")
} else {
Log.d(TAG, "解除注册失败")
}
}
}
}
复制代码

这样,服务端的逻辑就完成了。

Step 3 客户端实现

客户端的实现在另一个 App 中实现,创建一个新项目,包名 com.chunyu.aidl.client ,将这个项目中的 MainActivity 作为 Client 。

Client 中需要实现的核心逻辑包括:

  • 创建 Client 到 Server 的连接。
  • 实现发送请求的功能。
  • 实现接收 Server 消息的功能。
  • 在销毁的生命周期中主动关闭连接。

创建连接

创建连接主要通过 bindService 来实现,分为两种情况,在同一个进程中,不需要跨进程,直接通过显式的 Intent 启动 Service :

val intent = Intent(this, MyService::class.java)
bindService(intent, connection!!, BIND_AUTO_CREATE)
复制代码

这是因为在同一个进程中,能直接访问到服务端 Service 的类。而跨进程没有这个 class ,需要通过隐式 Intent 启动 :

val intent = Intent()
intent.action = "com.chunyu.aidl.service.MyService"
intent.setPackage("com.chunyu.aidl.service")
bindService(intent, connection!!, BIND_AUTO_CREATE)
复制代码

Action 在实现服务端时,在 AndroidManifest.xml 中进行配置,此刻就用到了。

而不管是哪个进程调用 bindService ,都会需要一个 connection 参数,这是一个 ServiceConnection 的对象。

ServiceConnection 是一个监控应用 Service 状态的接口。和很多系统的其他回调一样,这个接口的实现的方法在进程的主线程中调用。

public interface ServiceConnection {

void onServiceConnected(ComponentName name, IBinder service);

void onServiceDisconnected(ComponentName name);

default void onBindingDied(ComponentName name) {
}

default void onNullBinding(ComponentName name) {
}
}
复制代码

了解了 ServiceConnection ,所以需要在 Client 中实现一个 ServiceConnection 对象,这里用匿名对象的形式:

private var deathRecipient = object : IBinder.DeathRecipient {
override fun binderDied() {
iMsgManager?.let {
// 当 binder 连接断开时,解除注册
it.asBinder().unlinkToDeath(this, 0)
iMsgManager = null
}
}
}

val connection = object : ServiceConnection {
// 服务连接创建成功时
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
iMsgManager = IMsgManager.Stub.asInterface(binder)
try {
iMsgManager?.asBinder()?.linkToDeath(deathRecipient, 0)
iMsgManager?.registerReceiveListener(receiveMsgListener)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun onServiceDisconnected(name: ComponentName?) { }
}
复制代码

接收 Server 消息

这里注册的 listener是一个 IReceiveMsgListener.Stub 对象:

    private var receiveMsgListener = object : IReceiveMsgListener.Stub() {
override fun onReceive(msg: Msg?) {
displayTv.post {
displayTv.text = "客户端:${msg?.msg}, time: ${msg?.time}"
}
}
}
复制代码

写到这里的时候,我是有一个疑问的,为什么是 IReceiveMsgListener.Stub 类型,而不是一个 IReceiveMsgListener 的匿名对象。

首先 IReceiveMsgListener.Stub 的继承关系是:

public static abstract class Stub extends android.os.Binder implements com.chunyu.aidl.service.IReceiveMsgListener
复制代码

IReceiveMsgListener.Stub 本身也是 IReceiveMsgListener 的实现,而且它还继承自 Binder ,也就是具备了 Binder 的能力,能够在跨进程通信中作为 Binder 传输,所以这里是 IReceiveMsgListener.Stub 类型的对象。

而如果直接使用 IReceiveMsgListener , 匿名对象要求多实现一个 asBinder 方法:

    private var receiveMsgListener = object : IReceiveMsgListener {
override fun asBinder(): IBinder {
TODO("Not yet implemented")
}

override fun onReceive(msg: Msg?) {
displayTv.post {
displayTv.text = "客户端:${msg?.msg}, time: ${msg?.time}"
}
Log.d(TAG, "客户端:$msg")
}
}
复制代码

其实 IReceiveMsgListener.Stub 就是帮我们实现好了一些逻辑,减少了开发的复杂度。

而从另一个方面也验证了 AIDL 跨进程通信调用的对象能需要具备作为 Binder 的能力。

Client 发送消息

val connection = object : ServiceConnection {
// 服务连接创建成功时
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
iMsgManager = IMsgManager.Stub.asInterface(binder)
try {
iMsgManager?.asBinder()?.linkToDeath(deathRecipient, 0)
iMsgManager?.registerReceiveListener(receiveMsgListener)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun onServiceDisconnected(name: ComponentName?) { }
}
复制代码

onServiceConnected 代表着连接创建成功了,此时首先赋值了 iMsgManager ,iMsgManager 是 AIDL 中定义的 Server 暴露给 Client 的能力的接口对象:

private var iMsgManager: IMsgManager?  = null
复制代码

它的初始化:

iMsgManager = IMsgManager.Stub.asInterface(binder)
复制代码

这里的 asInerface 和 asBinder 方法,说明 AIDL 中定义的接口可以转换为 Binder ,Binder 也可以转换为 Interface ,因为这里的 Binder 来自于 Service 的 onBind 方法,在 onBind 中返回的就是 IMsgManager.Stub 的实现,自然可以转换为 IMsgManager 。

通过 IMsgManager 就可以调用 Server 中的方法了:

        sendMsgBtn.setOnClickListener {
iMsgManager?.sendMsg(Msg("from 客户端,当前第 ${count++} 次", System.currentTimeMillis()))
}
复制代码

随便给一个 button 的点击事件中调用 sendMsg 方法,发送消息给 MyService 。

生命周期管理

最后是在 Client 的生命周期中及时关闭连接,清除不需要的对象。

    // in MainActivity
override fun onDestroy() {
if (iMsgManager?.asBinder()?.isBinderAlive == true) {
try {
iMsgManager?.unregisterReceiveListener(receiveMsgListener)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
connection?.let {
unbindService(it)
}
super.onDestroy()
}
复制代码

基本上这就是一个完整的 AIDL 流程了。

总结

  • AIDL 是一套快速实现 Android Binder 机制的框架。
  • Android 中的 Binder 机制,架构思想是 C / S 架构。
  • 所有跨进程传递的数据需要实现 Parcelable (除了一些基本的类型)。
  • 所有跨进程调用的对象,都必须是 Binder 的实现。
  • Binder 对象可以和 Interface 实例进行转换,这是因为 Service 中返回的 Binder 对象实现了 Interface。
  • 通过 aidl 文件中定义的接口,可以跨进程调用远程对象的方法。


作者:自动化BUG制造器
链接:https://juejin.cn/post/7123129439898042376
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

千万不要用JSON.stringify()去实现深拷贝!有巨坑!!

当对象中有时间类型的元素时候 -----时间类型会被变成字符串类型数据const obj = { date:new Date()}typeof obj.date === 'object' //trueconst objCopy = JSON.parse(...
继续阅读 »

当对象中有时间类型的元素时候 -----时间类型会被变成字符串类型数据

const obj = {
date:new Date()
}
typeof obj.date === 'object' //true
const objCopy = JSON.parse(JSON.stringify(obj));
typeof objCopy.date === string; //true

然后你就会惊讶的发现,getTime()调不了了,getYearFull()也调不了了。就所有时间类型的内置方法都调不动了。

但,string类型的内置方法全能调了。

当对象中有undefined类型或function类型的数据时 --- undefined和function会直接丢失

const obj = {
undef: undefined,
fun: () => { console.log('叽里呱啦,阿巴阿巴') }
}
console.log(obj,"obj");
const objCopy = JSON.parse(JSON.stringify(obj));
console.log(objCopy,"objCopy")

然后你就会发现,这两种类型的数据都没了。

当对象中有NaN、Infinity和-Infinity这三种值的时候 --- 会变成null

1.7976931348623157E+10308 是浮点数的最大上线 显示为Infinity

-1.7976931348623157E+10308 是浮点数的最小下线 显示为-Infinity

const obj = {
nan:NaN,
infinityMax:1.7976931348623157E+10308,
infinityMin:-1.7976931348623157E+10308,
}
console.log(obj, "obj");
const objCopy = JSON.parse(JSON.stringify(obj));
console.log(objCopy,"objCopy")

当对象循环引用的时候 --会报错

const obj = {
objChild:null
}
obj.objChild = obj;
const objCopy = JSON.parse(JSON.stringify(obj));
console.log(objCopy,"objCopy")

假如你有幸需要拷贝这么一个对象 ↓

const obj = {
nan:NaN,
infinityMax:1.7976931348623157E+10308,
infinityMin:-1.7976931348623157E+10308,
undef: undefined,
fun: () => { console.log('叽里呱啦,阿巴阿巴') },
date:new Date,
}

然后你就会发现,好家伙,没一个正常的。

image.png

你还在使用JSON.stringify()来实现深拷贝吗?

如果还在使用的话,小心了。作者推荐以后深拷贝使用递归的方式进行深拷贝。

原文:https://juejin.cn/post/7113829141392130078

收起阅读 »

转行的程序猿都去做什么了?这些个案羡煞我也

程序猿是口青春饭,30有点“老”,35非常“老”。当年龄越来越大,体力精力学习能力越来越竞争不过年轻人时,除了技术和管理,码农还有没有别的选择吗?以下这些切实又不切实的选择仅供参考。 1.转往临近岗位,比如你讨厌的产品经理 程序猿和产品经理可谓是最像夫妻的两个...
继续阅读 »

程序猿是口青春饭,30有点“老”,35非常“老”。当年龄越来越大,体力精力学习能力越来越竞争不过年轻人时,除了技术和管理,码农还有没有别的选择吗?以下这些切实又不切实的选择仅供参考。


1.转往临近岗位,比如你讨厌的产品经理


程序猿和产品经理可谓是最像夫妻的两个职位,相爱相杀,知根知底。


程序员转产品经理有很大优势,因为了解产品的实现过程,所以对项目的时间把握有相当的话语权,能保证了项目的进度,对产品将来的扩展和升级都有帮助,所以程序员转过来的产品经理是很抢手的。


只要多学习一些产品营销和运营方面的知识,多一点沟通能力,程序员出生的产品经理就自然而然在市场上占据很大优势。



国内目前最牛逼的产品经理非微信之父张小龙莫属,他就是程序员转产品经理的最佳案例。如果你拥有绝佳的洞察力,能够了解人性需求,相信自己可以创造出人人都愿意的产品,你也可以像张小龙一样,升职加薪、当上总经理、出任CEO、迎娶白富美、走上人生巅峰。


2.任何行业的教育行业都可以作为备胎


随着猿在劳动力市场的走红,IT讲师也成为了一个热门职位。大龄码农在工作中年限越长上升越慢,到大龄阶段,就不得不面对自己的停滞,IT讲师对于他们会是一个很好的选择。


开发一线需要越年轻越好,这样薪水低能加班,不过IT讲师可谓是越老越有“味道”,资历越丰厚,越能吸引学员。因此相当一部分“退役程序猿”都转去了IT教育行业。


3.个体户,谁自由谁知道


除了做IT讲师和产品经理,有的程序猿纯粹是写腻了代码,想完全摆脱代码,摆脱程序猿的工作。他们有的就利用自己多年攒下的钱,做起了个体户。


不知是因为新闻性还是真有那么大基数,据说程序猿转行做餐饮的不少,并且都还做得不错。


无论是卖肠粉的,卖凉皮的,卖热干面,卖火锅的都有!






程序猿卖烧饼,卖凉皮,听上去有些屈才。但如今的个体户收入并不低,即使只是卖个热干面,卖个烧饼,好好经营,收入不比一个高级码农差。


4.一些高薪产业,比如做明星


此条建议,是认真的,毕竟明星中有不少的程序猿转行成功的案例。


潘玮柏


潘玮柏,曾设计一款游戏——熊猫屁王,他是第一个设计 iPhone app 的艺人, 还创造过 App Store 上下载量第一。


这也就算了,别的明星送礼都是送花送大牌,潘玮柏给周杰伦的发片礼竟然是以周杰伦为游戏主角的手机游戏。


李健


男神李健,原来也是码农。他是清华高材生,大学里学习的就是电子工程专业。李健的第一份工作就是在国家广电总局当一名网络工程师。


马东


奇葩说的主持人马东也曾经是一名计算机专业的人才。在他还未满18岁的时候,马东就只身前往澳洲学习计算机专业,在澳洲有着10年的IT工程师经历。而这使得他对数据有着灵敏的认知和有序的编程思维,能够在传媒行业风格别具一致,脱颖而出。


除了这些明星是程序猿转行过来的,还有猿混成了总理。




李显龙


新加坡总理李显龙也曾是位真正入行的程序员。他在剑桥大学三一学院修习数学和计算机科学,曾把自己写过的解熟读程序放了出来,让网民们一起帮忙找BUG。李显龙在一次科技创业论坛上透露,自己曾“非常享受编程”。


5.追求自己的爱好,找寻真正的自我


有人说,最好的转行是为了兴趣爱好而转。毕竟换一个行业需要投入大量时间精力,还要面对失败。做一个程序猿,奋斗几年,攒够一定资金后,就该找寻自我,追求自己的爱好了。以下两个案例中的程序猿,都曾有稳定的程序猿工作,但都放弃了选择了追求自己的爱好,最终闯出了一片天。


闫鹤祥


著名德云社相声演员——闫鹤祥原是一名程序猿。闫鹤祥曾在社交软件上晒出他当年任中国移动数字大厦无线局域网管理工程师职位。从一个程序员到相声演员,闫鹤祥从幕后转到台前,由原先的默默无闻变成了现在为德云社少帮主郭麒麟捧哏的相声大腕儿。


王小波


大多数人都知道王小波是小说家,却很少人知道王小波算得上是中国早期的程序员,在90年代初国内应用软件缺乏的时候,王小波学会了汇编和C语言,编了中文编辑器和输入法,相比同期的雷军、求伯君,王小波的编程能力毫不逊色。


王小波曾在自己的小说里骄傲地写到,我写书的软件都是自己编写的。当时的他还通过卖软件还赚了不少钱呢,很多中关村的老板想要拉他入伙,这对于当时的屌丝王小波还是有致命吸引力的,王小波也认真地考虑过,只不过后来觉得写东西更有意思,便一一回绝了。


以上为不完全程序猿转行方向手册,无论如何,转行需谨慎。



链接:https://juejin.cn/post/6844903829507407879

收起阅读 »

Go语言负责人离职后,一门国产语言诞生了

Go
1 事件回顾 上周,谷歌Go语言项目负责人Steve Francia宣布辞去职务,而他给出理由是:Go项目的工作停滞不前,让他感到难受。有意思的是,部分国内的Gopher(Go语言爱好者的自称)对Go语言也产生了新想法。比如,国内第一批Go语言爱好者之一的柴树...
继续阅读 »

1 事件回顾

上周,谷歌Go语言项目负责人Steve Francia宣布辞去职务,而他给出理由是:Go项目的工作停滞不前,让他感到难受。

有意思的是,部分国内的Gopher(Go语言爱好者的自称)对Go语言也产生了新想法。比如,国内第一批Go语言爱好者之一的柴树杉、全球Go贡献者榜上长期排名TOP 50的史斌等Gopher,他们决定以Go语言为蓝本,发起新的编程语言:凹语言™(凹读音“Wa”)。

目前凹语言™的代码已经在Github开源,并且提供了简单可执行的示例。根据其仓库的介绍,凹语言™的设计目标有以下几个:

1、披着Go和Rust语法外衣的C++语言

2、凹语言™源码文件后缀为.wa

3、凹语言™编译器兼容WaGo语法,凹语法与WaGo语法在AST层面一致(二者可生成相同的AST并无损的互相转换)

4、凹语言™支持中文/英文双语关键字,即任一关键字均有中文版和英文版,二者在语法层面等价


凹语言™示意,图片来源@GitHub

据柴树杉、史斌等人的说法,Go语言“克制”的风格是他们对编程语言审美的最大公约数。因此,凹语言™项目启动时大量借鉴了Go的设计思想和具体实现。

当然,他们也表示,选择Go语言作为初始的蓝本,是在有限投入下不得不作出的折衷。他们希望随着项目的发展,积累更多原创的设计,为自主创新的大潮贡献一点力量。

虽说柴树杉、史斌等人是资深的Gopher,偏爱Go语言并不难理解,但我们还是忍不住好奇:究竟Go语言有多神奇,让他们对Go语言这么着迷?

2 为什么选中Go语言

从许多使用过Go语言的开发者对Go的评价上看,Go语言在设计上有以下四个特点。

1、简单易用

不同于那些通过相互借鉴而不断增加新特性的主流编程语言(如C++、Java等),Go的设计者们在语言设计之初就拒绝走语言特性融合的道路,而选择了“做减法”。

他们把复杂留给了语言自身的设计和实现,留给了Go核心开发组,而将简单、易用和清晰留给了广大使用Go语言的开发者。因此,Go语言呈现出:

  • 简洁、常规的语法(不需要解析符号表),仅有25个关键字;

  • 没有头文件;

  • 显式依赖(package);

  • 没有循环依赖(package);

  • 常量只是数字;

  • 首字母大小写决定可见性;

  • 任何类型都可以拥有方法(没有类);

  • 没有子类型继承(没有子类);

  • 没有算术转换;

  • 没有构造函数或析构函数;

  • 赋值不是表达式;

  • 在赋值和函数调用中定义的求值顺序(无“序列点”概念);

  • 没有指针算术;

  • 内存总是初始化为零值;

  • 没有类型注解语法(如C++中的const、static等)

  • ……

2、偏好组合

C++、Java等主流面向对象语言,通过庞大的自上而下的类型体系、继承、显式接口实现等机制,将程序的各个部分耦合起来,但在Go语言中我们找不到经典面向对象的语法元素、类型体系和继承机制。

那Go语言是如何将程序的各个部分耦合在一起呢?是组合。

在语言设计层面,Go使用了正交的语法元素,包括Go语言无类型体系,类型之间是独立的,没有子类型的概念;每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的。

各类型之间通过类型嵌入,将已经实现的功能嵌入新类型中,以快速满足新类型的功能需求。在通过新类型实例调用方法时,方法的匹配取决于方法名字,而不是类型。

另外,通过在接口的定义中嵌入接口类型来实现接口行为的聚合,组成大接口,这种方式在标准库中尤为常用,并且已经成为Go语言的一种惯用法。

这是Go语言的一个创新设计:接口只是方法集合,且与实现者之间的关系是隐式的,如此可让程序各个部分之间的耦合降至最低。

3、并发和轻量

Go语言的三位设计者Rob Pike、Robert Griesemer和Ken Thompson曾认为C++标准委员会在思路上是短视的,因为硬件很可能在未来十年内发生重大变化,将语言与当时的硬件紧密耦合起来是十分不明智的,是没法给开发人员在编写大规模并发程序时带去太多帮助的。

因而他们把将面向多核、原生内置并发支持作为新语言的设计原则之一。

Go语言原生支持并发的设计哲学体现在下面两点。

(1)Go语言采用轻量级协程并发模型,使得Go应用在面向多核硬件时更具可扩展性。

(2)Go语言为开发者提供的支持并发的语法元素和机制。

4、面向工程

Go语言的设计者在Go语言最初设计阶段,就将解决工程问题作为Go的设计原则之一,进而考虑Go语法、工具链与标准库的设计,这也是Go与那些偏学院派、偏研究性编程语言在设计思路上的一个重大差异。

这让Go语言的规范足够简单灵活,有其他语言基础的程序员都能迅速上手。更重要的是Go自带完善的工具链,大大提高了团队协作的一致性。比如Gofmt自动排版Go代码,很大程度上杜绝了不同人写的代码排版风格不一致的问题。把编辑器配置成在编辑存档的时候自动运行Gofmt,这样在编写代码的时候可以随意摆放位置,存档的时候自动变成正确排版的代码。此外还有Gofix,Govet等非常有用的工具。

总之,Go在语言层面的简单让Go收获了不逊于C++/Java等的表现力的同时,还获得了更好的可读性、更高的开发效率等在软件工程领域更为重要的元素。

3 凹语言™的未来

虽然今天,Go凭借其优越的性能,已经成为主流编程语言之一(超过75%的CNCF项目,包括Kubernetes和Istio,都是用Go编写的,另外,Go也是主要的云应用程序语言之一),Go语言在中国也相当受欢迎,但我们还是不禁担心脱胎于Go的凹语言™,会有美好的未来吗?


Go语言搜索热度,图片来源@Google Trend

预测未来从来都是困难的,不过,好在凹语言™的前面有一个先行者——Go+语言,我们不妨基于Go+的发展,来大致推测凹语言™的未来。

Go+是七牛云CEO许式伟发明的编程语言,于2020年7月正式发布,2021年10月推出1.0版本,目前最新发布版本是今年6月13日发布的1.1版本。也就是说,从正式发布到现在,经过近两年的时间,Go+还处于初始阶段,距离大规模应用还有一定距离,那么可以预见,凹语言™在未来相当长的时间里,不会进入广大开发者的视野中。

另外,据ECUG Con 2022大会上许式伟发表的看法,虽然大家都比较看重编程语言的性能,但单从性能来看的话,许式伟认为Python在脚本语言里面只能算二流,Python其实并不快。

在许式伟看来,对新生的语言来说,最重要它选择的目标人群。

Go+选择的目标人群是全民,许式伟称其为“连儿童也能掌握的语言”,因而Go+从工程与STEM教育的一体化开始奠定用户基础。

正是Go+的这几个特性,让一部分开发者看好Go+的未来。而对Go+的正向预期,会成为Go+进一步发展的助力。

对凹语言™来说,这个道理也是适用的:凹语言™的发展重点可能不在于性能,而在于其选择哪些人群作为目标受众,以及通过何种方式获得种子用户。

如果日后凹语言™的项目方会公布这些消息,那么凹语言™的未来还是可以期待的。

来源:武穆、信远(51CTO技术栈)

收起阅读 »

快速搭建一个网关服务,动态路由、鉴权的流程,看完秒会(含流程图)

最近发现网易号有盗掘金文章的,xdm有空可以关注一下这个问题,希望帮助到大家同时能够保障自己权益。前言本文记录一下我是如何使用Gateway搭建网关服务及实现动态路由的,帮助大家学习如何快速搭建一个网关服务,了解路由相关配置,鉴权的流程及业务处理,有兴趣的一定...
继续阅读 »


最近发现网易号有盗掘金文章的,xdm有空可以关注一下这个问题,希望帮助到大家同时能够保障自己权益。

前言

本文记录一下我是如何使用Gateway搭建网关服务及实现动态路由的,帮助大家学习如何快速搭建一个网关服务,了解路由相关配置,鉴权的流程及业务处理,有兴趣的一定看到最后,非常适合没接触过网关服务的同学当作入门教程。

搭建服务

框架

  • SpringBoot 2.1

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.1.0.RELEASE</version>
</parent>
  • Spring-cloud-gateway-core

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-gateway-core</artifactId>
</dependency>
  • common-lang3

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
</dependency>

路由配置

网关作为请求统一入口,路由就相当于是每个业务系统的入口,通过路由规则则可以匹配到对应微服务的入口,将请求命中到对应的业务系统中

server:
port: 8080

spring:
cloud:
  gateway:
    enabled: true
    routes:
    - id: demo-server
      uri: http://localhost:8081
      predicates:
      - Path=/demo-server/**
      filters:
        - StripPrefix= 1

routes

配置项描述
id路由唯一id,使用服务名称即可
uri路由服务的访问地址
predicates路由断言
filters过滤规则

解读配置

  • 现在有一个服务demo-server部署在本机,地址和端口为127.0.0.1:8081,所以路由配置uri为http://localhost:8081

  • 使用网关服务路由到此服务,predicates -Path=/demo-server/**,网关服务的端口为8080,启动网关服务,访问localhost:8080/demo-server,路由断言就会将请求路由到demo-server

  • 直接访问demo-server的接口localhost:8081/api/test,通过网关的访问地址则为localhost:8080/demo-server/api/test,predicates配置将请求断言到此路由,filters-StripPrefix=1代表将地址中/后的第一个截取,所以demo-server就截取掉了

使用gateway通过配置文件即可完成路由的配置,非常方便,我们只要充分的了解配置项的含义及规则就可以了;但是这些配置如果要修改则需要重启服务,重启网关服务会导致整个系统不可用,这一点是无法接受的,下面介绍如何通过Nacos实现动态路由

动态路由

使用nacos结合gateway-server实现动态路由,我们需要先部署一个nacos服务,可以使用docker部署或下载源码在本地启动,具体操作可以参考官方文档即可

Nacos配置


groupId: 使用网关服务名称即可

dataId: routes

配置格式: json

[{
     "id": "xxx-server",
     "order": 1, #优先级
     "predicates": [{ #路由断言
         "args": {
             "pattern": "/xxx-server/**"
        },
         "name": "Path"
    }],
     "filters":[{ #过滤规则
         "args": {
             "parts": 0 #k8s服务内部访问容器为http://xxx-server/xxx-server的话,配置0即可
        },
         "name": "StripPrefix" #截取的开始索引
    }],
     "uri": "http://localhost:8080/xxx-server" #目标地址
}]

json格式配置项与yaml中对应,需要了解配置在json中的写法

比对一下json配置与yaml配置

{
   "id":"demo-server",
   "predicates":[
      {
           "args":{
               "pattern":"/demo-server/**"
          },
           "name":"Path"
      }
  ],
   "filters":[
      {
           "args":{
               "parts":1
          },
           "name":"StripPrefix"
      }
  ],
   "uri":"http://localhost:8081"
}
spring:
 cloud:
   gateway:
     enabled: true
     routes:
     - id: demo-server
       uri: http://localhost:8081
       predicates:
       - Path=/demo-server/**
       filters:
         - StripPrefix= 1

代码实现

Nacos实现动态路由的方式核心就是通过Nacos配置监听,配置发生改变后执行网关相关api创建路由


@Component
public class NacosDynamicRouteService implements ApplicationEventPublisherAware {

   private static final Logger LOGGER = LoggerFactory.getLogger(NacosDynamicRouteService.class);

   @Autowired
   private RouteDefinitionWriter routeDefinitionWriter;

   private ApplicationEventPublisher applicationEventPublisher;

   /** 路由id */
   private static List<String> routeIds = Lists.newArrayList();

   /**
    * 监听nacos路由配置,动态改变路由
    * @param configInfo
    */
   @NacosConfigListener(dataId = "routes", groupId = "gateway-server")
   public void routeConfigListener(String configInfo) {
       clearRoute();
       try {
           List<RouteDefinition> gatewayRouteDefinitions = JSON.parseArray(configInfo, RouteDefinition.class);
           for (RouteDefinition routeDefinition : gatewayRouteDefinitions) {
               addRoute(routeDefinition);
          }
           publish();
           LOGGER.info("Dynamic Routing Publish Success");
      } catch (Exception e) {
           LOGGER.error(e.getMessage(), e);
      }
       
  }


   /**
    * 清空路由
    */
   private void clearRoute() {
       for (String id : routeIds) {
           routeDefinitionWriter.delete(Mono.just(id)).subscribe();
      }
       routeIds.clear();
  }

   @Override
   public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
       this.applicationEventPublisher = applicationEventPublisher;
  }

   /**
    * 添加路由
    *
    * @param definition
    */
   private void addRoute(RouteDefinition definition) {
       try {
           routeDefinitionWriter.save(Mono.just(definition)).subscribe();
           routeIds.add(definition.getId());
      } catch (Exception e) {
           LOGGER.error(e.getMessage(), e);
      }
  }

   /**
    * 发布路由、使路由生效
    */
   private void publish() {
       this.applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this.routeDefinitionWriter));
  }
}

过滤器

gateway提供GlobalFilter及Ordered两个接口用来定义过滤器,我们自定义过滤器只需要实现这个两个接口即可

  • GlobalFilter filter() 实现过滤器业务

  • Ordered getOrder() 定义过滤器执行顺序

通常一个网关服务的过滤主要包含 鉴权(是否登录、是否黑名单、是否免登录接口...) 限流(ip限流等等)功能,我们今天简单介绍鉴权过滤器的流程实现

鉴权过滤器

需要实现鉴权过滤器,我们先得了解登录及鉴权流程,如下图所示

由图可知,我们鉴权过滤核心就是验证token是否有效,所以我们网关服务需要与业务系统在同一个redis库,先给网关添加redis依赖及配置

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
spring:
redis:
  host: redis-server
  port: 6379
  password:
  database: 0

代码实现

  • 1.定义过滤器AuthFilter

  • 2.获取请求对象 从请求头或参数或cookie中获取token(支持多种方式传token对于客户端更加友好,比如部分web下载请求会新建一个页面,在请求头中传token处理起来比较麻烦)

  • 3.没有token,返回401

  • 4.有token,查询redis是否有效

  • 5.无效则返回401,有效则完成验证放行

  • 6.重置token过期时间、添加内部请求头信息方便业务系统权限处理

@Component
public class AuthFilter implements GlobalFilter, Ordered {

@Autowired
private RedisTemplate<String, String> redisTemplate;

private static final String TOKEN_HEADER_KEY = "auth_token";

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1.获取请求对象
ServerHttpRequest request = exchange.getRequest();
// 2.获取token
String token = getToken(request);
ServerHttpResponse response = exchange.getResponse();
if (StringUtils.isBlank(token)) {
// 3.token为空 返回401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 4.验证token是否有效
String userId = getUserIdByToken(token);
if (StringUtils.isBlank(userId)) {
// 5.token无效 返回401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// token有效,后续业务处理
// 从写请求头,方便业务系统从请求头获取用户id进行权限相关处理
ServerHttpRequest.Builder builder = exchange.getRequest().mutate();
request = builder.header("user_id", userId).build();
// 延长缓存过期时间-token缓存用户如果一直在操作就会一直重置过期
// 这样避免用户操作过程中突然过期影响业务操作及体验,只有用户操作间隔时间大于缓存过期时间才会过期
resetTokenExpirationTime(token, userId);
// 完成验证
return chain.filter(exchange);
}


@Override
public int getOrder() {
// 优先级 越小越优先
return 0;
}

/**
* 从redis中获取用户id
* 在登录操作时候 登陆成功会生成一个token, redis得key为auth_token:token 值为用户id
*
* @param token
* @return
*/
private String getUserIdByToken(String token) {
String redisKey = String.join(":", "auth_token", token);
return redisTemplate.opsForValue().get(redisKey);
}

/**
* 重置token过期时间
*
* @param token
* @param userId
*/
private void resetTokenExpirationTime(String token, String userId) {
String redisKey = String.join(":", "auth_token", token);
redisTemplate.opsForValue().set(redisKey, userId, 2, TimeUnit.HOURS);
}


/**
* 获取token
*
* @param request
* @return
*/
private static String getToken(ServerHttpRequest request) {
HttpHeaders headers = request.getHeaders();
// 从请求头获取token
String token = headers.getFirst(TOKEN_HEADER_KEY);
if (StringUtils.isBlank(token)) {
// 请求头无token则从url获取token
token = request.getQueryParams().getFirst(TOKEN_HEADER_KEY);
}
if (StringUtils.isBlank(token)) {
// 请求头和url都没有token则从cookies获取
HttpCookie cookie = request.getCookies().getFirst(TOKEN_HEADER_KEY);
if (cookie != null) {
token = cookie.getValue();
}
}
return token;
}
}

总结

Gateway通过配置项可以实现路由功能,整合Nacos及配置监听可以实现动态路由,实现GlobalFilter, Ordered两个接口可以快速实现一个过滤器,文中也详细的介绍了登录后的请求鉴权流程,如果有不清楚地方可以评论区见咯。

来源:juejin.cn/post/7004756545741258765

收起阅读 »

如何利用 Flutter 实现炫酷的 3D 卡片和帅气的 360° 展示效果

本篇将带你在 Flutter 上快速实现两个炫酷的动画特效,希望最后的效果可以惊艳到你。 这次灵感的来源于更新 MIUI 13 时刚好看到的卡片效果,其中除了卡片会跟随手势出现倾斜之外,内容里的部分文本和绿色图标也有类似悬浮的视差效果,恰逢此时灵机一动,我们也...
继续阅读 »

本篇将带你在 Flutter 上快速实现两个炫酷的动画特效,希望最后的效果可以惊艳到你。


这次灵感的来源于更新 MIUI 13 时刚好看到的卡片效果,其中除了卡片会跟随手势出现倾斜之外,内容里的部分文本和绿色图标也有类似悬浮的视差效果,恰逢此时灵机一动,我们也来用 Flutter 快速实现炫酷的 3D 视差卡片,最后再拓展实现一个支持帅气的 360° 展示的卡片效果



❤️ 本文正在参加征文投稿活动,还请看官们走过路过来个点赞一键三连,感激不尽~




既然需要卡片跟随手势产生不规则形变,我们第一个想到的肯定是矩阵变换,在 Flutter 里我们可以使用 Matrix4 配合 Transform 来实现矩阵变换效果。


开始之前,首先我们创建用 Transform 嵌套一个 GestureDetector ,并绘制出一个 300x400 的圆角卡片,用于后续进行矩阵变换处理。


Transform(
transform: Matrix4.identity(),
child: GestureDetector(
child: Container(
width: 300,
height: 400,
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blueGrey,
borderRadius: BorderRadius.circular(20),
),
),
),
);


接着,如下代码所示,因为我们需要卡片跟随手势进行矩阵变换,所以我们可以直接在 GestureDetectoronPanUpdate 里获取到手势信息,例如 localPosition 位置信息,然后把对应的 dxdy赋值到 Matrix4rotateXrotateY 上实现旋转。


child: Transform(
transform: Matrix4.identity()
..rotateX(touchY)
..rotateY(touchX),
alignment: FractionalOffset.center,
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
touchX = details.localPosition.dx;
touchY = details.localPosition.dy;
});
},
child: Container(

这里有个需要注意的是:上面代码里 rotateX 使用的是 touchY ,而 rotateY 使用的是 touchX ,为什么要这样做呢?



⚠️举个例子,当我们手指左右移动时,是希望卡片可以围绕 Y 轴进行旋转,所以我们会把 touchX 传递给了 rotateY ,同样 touchY 传递给 rotateX 也是一个道理。




但是当我们实际运行上述代码之后,如下图所示,可以看到基本上我们只是稍微移动手指,卡片就会陷入疯狂旋转的情况,并且实际的旋转速度会比 GIF 里快很多。



问题的原因其实是因为 rotateXrotateY 需要的是一个 angle 参数,假设这里对 rotateXrotateY 设置 pi / 4 ,就可以看到卡片在 X 轴和 Y 轴上都产生了 45 度的旋转效果。


 Transform(
transform: Matrix4.identity()
..rotateX(pi / 4)
..rotateY(pi / 4),
alignment: FractionalOffset.center,


所以如果直接使用手势的 localPosition 作用于 Matrix4 肯定是不行的,我们首先需要对手势数据进行一个采样,因为代码里我们设置了 FractionalOffset.center ,所以我们可以用卡片的中心点来计算手指位置,再进行压缩处理


如下代码所示,我们通过以卡片中心点为原点进行计算,其中 / 2 就是得到卡片的中心点,/ 100 是对数据进行压缩采样,但是为什么 touchXtouchY 的计算方式是相反的呢


touchX = (cardWidth / 2 - details.localPosition.dx) / 100;
touchY = (details.localPosition.dy - cardHeight / 2 ) / 100;

如下图所示,因为在设置 rotateXrotateY 时,赋予 > 0 的数据时卡片就会以图片中的方向进行旋转,由于我们是需要手指往哪边滑动,卡片就往哪边倾斜,所以:



  • 当我们往左水平滑动时,需要卡片往左边倾斜,也就是图中绕 Y 轴转动的 >0 的方向,并且越靠近左边需要正向的 Angle 数值越大,由于此时 localPosition.dx 是越往左越小,所以需要利用 CardWidth / 2 - details.localPosition.dx 进行计算,得到越往左有越大的正向 Angle 数值

  • 同理,当我们往下滑动时,需要卡片往下边倾斜,也就是图中绕 X 轴转动的 >0 的方向,并且越靠近下边需要正向 Angle 数值越大,由于此时 localPosition.dy 越往下越大,所以使用 details.localPosition.dy - cardHeight / 2 去计算得到正确数据










如果觉得太抽象,可以结合上边右侧的动图,和大家买股票一样,图中显示红色时是正数,显示绿色时是负数,可以看到:



  • 手指往左移动时,第一行 TouchX 是红色正数,被设置给 rotateY , 然后卡片绕 Y 轴正方向旋转

  • 手指往下移动时,第二行 TouchY 是红色正数,被设置给 rotateX , 然后卡片绕 X 轴正方向旋转


到这里我们就初步实现了卡片跟随手机旋转的效果,但是这时候的立体旋转效果看起来其实“很别扭”,总感觉差了点什么,其实这是因为卡片在旋转时没有产生视觉上的深度感知


所以我们可以通过矩阵的透视变换调整视觉效果,而为了在 Z 方向实现深度感知,我们需要在矩阵中配置 .setEntry(3, 2, 0.001) ,这里的 3 表示第 3 列,2 表示第 2 行,因为是从 0 开始排列,所以也就是图片中 Z 的位置。



其实 .setEntry(3, 2, 0.001) 就是调整 Z 轴的视角,而在 Z 上的 0.001 就是需要的透视效果测量值,类似于相机上的对焦点进行放大和缩小的作用,这个数字越大就会让交点处看起来好像离你视觉更近,所以最终代码如下


Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateX(touchY)
..rotateY(touchX),
alignment: FractionalOffset.center,

运行之后,可以看到在增加了 Z 角度的视角调整之后,这时候看起来的立体效果就好了很多,并且也有了类似 3D 空间的感觉。



接着我们在卡片上放上一个添加一个 13Text 文本,运行之后可以看到此时文本是跟随卡片发生变化,而接下来我们需要做的,就是通过另外一个 Transform 来让 Text 文本和卡片之间产生视差,从而出现悬浮的效果










所以接下来需要给文本内容设置一个 translateMatrix4 ,让它向着倾斜角度的相反方向移动,然后对前面的 touchXtouchY 进行放大,然后再通过 - 10 操作来产生一个位差。


    Transform(
transform: Matrix4.identity()
..translate(touchX * 100 - 10,
touchY * 100 - 10, 0.0),


-10 这个是我随意写的,你也可以根据自己的需求调节。



例如,这时候当卡片往左倾斜时,文字就会向右移动,从而产生视觉差的效果,得到类似悬浮的感觉。










完成这一步之后,接下来可以我们对文本内容进行一下美化处理,例如增加渐变颜色,添加阴影,更换字体,目的是让字体看起来更加具备立体的效果,这里使用的 shader ,也可以让文字在移动过程中出现不同角度的渐变效果










最后,我们还需要对卡片旋转进行一个范围约束,这里主要是通过卡片大小比例:



  • onPanUpdate 时对 touchXtouchY 进行范围约束,从而约束的卡片的倾斜角度

  • 增加了 startTransform 标志位,用于在 onTapUp 或者 onPanEnd 之后,恢复卡片回到默认状态的作用。


Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateX(startTransform ? touchY : 0.0)
..rotateY(startTransform ? touchX : 0.0),
alignment: FractionalOffset.center,
child: GestureDetector(
onTapUp: (_) => setState(() {
startTransform = false;
}),
onPanCancel: () => setState(() => startTransform = false),
onPanEnd: (_) => setState(() {
startTransform = false;
}),
onPanUpdate: (details) {
setState(() => startTransform = true);
///y轴限制范围
if (details.localPosition.dx < cardWidth * 0.55 &&
details.localPosition.dx > cardWidth * 0.3) {
touchX = (cardWidth / 2 - details.localPosition.dx) / 100;
}

///x轴限制范围
if (details.localPosition.dy > cardHeight * 0.4 &&
details.localPosition.dy < cardHeight * 0.6) {
touchY = (details.localPosition.dy - cardHeight / 2) / 100;
}
},
child:

到这里,我们只需要在全局再进行一些美化处理,运行之后就会如下图所示,再配合阴影和渐变效果,整体的视觉立体感会更强烈,此时我们基本就实现了一开始想要的功能,




完整代码可见: card_perspective_demo_page.dart


Web 体验地址,PC 端记得开 Chrome 手机模式: 3D 视差卡片



那有人可能就想问了: 学会了这个我们还可以实现什么


举个例子,比如我们可以实现一个 “伪3D” 的 360° 卡片效果,利用堆叠实现立体的电子银行卡效果。


依旧是前面的手势旋转逻辑,只是这里我们可以把具有前后画面的银行卡图片,通过 IndexedStack 嵌套起来,嵌套之后主要是根据旋转角度来调整 IndexedStack 里需要展示的图片,然后利用透视旋转来实现类似 3D 物体的 360° 旋转展示



这里的关键是通过手势旋转角度,判断当前需要展示 IndexedStack 里的哪个卡片,因为 Flutter 使用的 Skia 是 2D 渲染引擎,如果没有这部分逻辑,你就只会看到单张图片画面的旋转效果。


if (touchX.abs() % (pi * 3 / 2) >= pi / 2 ||
touchY.abs() % (pi * 3 / 2) >= pi / 2) {
showIndex = 0;
} else {
showIndex = 1;
}

运行效果如下图所示,可以看到在视差和图片切换的作用下,我们用很低的成本在 Flutter 上实现了 “伪3D” 的卡片的 360° 展示,类似的实现其实还可以用于一些商品展示或者页面切换的场景,本质上就是利用视差的效果,在 2D 屏幕上模拟现实中的画面效果,从而达到类似 3D 的视觉作用










最后我们只需要用 Text 在卡片上添加“模拟”凹凸的文字,就实现了我们现实中类似银行卡的卡面效果




完整代码可见: card_3d_demo_page.dart


Web 体验地址,PC 端记得开 chrome 手机模式: 360° 可视化 3D 电子银行卡



好了,本篇动画特效就到此为止,如果你有什么想法,欢迎留言评论,感谢大家耐心看完,也还请看官们走过路过的来个点赞一键三连,感激不尽


作者:恋猫de小郭
链接:https://juejin.cn/post/7124064789763981326
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android Native 异常捕获库

Android Native 异常捕获库 基于google/breakpad的Android Native 异常捕获库,在native层发生异常时java层能得到相关异常信息。 项目主页 现状 发生native异常时,安卓系统会将native异常信息输...
继续阅读 »

Android Native 异常捕获库


image image image


基于google/breakpad的Android Native 异常捕获库,在native层发生异常时java层能得到相关异常信息。


项目主页


现状



  • 发生native异常时,安卓系统会将native异常信息输出到logcat中,但是java层无法感知到native异常的发生,进而无法获取这些异常信息并上报到业务的异常监控系统。

  • 业务部门可以快速实现java层的异常监控系统(java层全局异常捕获的实现很简单),又或者业务部门已经实现了java层的异常监控系统,但没有覆盖到native层的异常捕获。

  • 安卓还可以接入Breakpad,其导出的minidump文件不仅体积小信息还全,但有两个问题:

    • 1.和现状第1点的问题相同。

    • 2.:需要拉取minidump文件并经过比较繁琐的步骤才可以得出有用的信息:

      • 启动时检测Breakpad是否有导出过minidump文件,有则说明发生过native异常。

      • 到客户现场,或者远程拉取minidump文件。

      • 编译出自己电脑的操作系统的minidump_stackwalk工具。

      • 使用minidump_stackwalk工具翻译minidump文件内容,例如拿到崩溃时的程序计数器寄存器内的值(下文称为pc值)。

      • 找到对应崩溃so库ABI的add2line工具,并根据上一步拿到的pc值定位出发生异常的代码行数。






整个步骤十分复杂和繁琐,且没有java层的crash线程栈信息,不利于java开发者快速定位调用native的代码。


设计意图



  1. 让java层有知悉native异常的通道:

    • java开发者可以在java代码中得到native异常的情况,进而对native异常做出反应,而不是再次启动后去检测Breakpad是否有导出过minidump文件。



  2. 增加信息的可用性,进而提升问题分析的效率:


    • 回调中提供naive异常信息、naive和java调用栈信息和minidump文件文件路径,这些信息可以直接通过业务部门的异常监控系统上报。




    • 划分为两个阶段解决问题,我预想是大部分都在阶段一解决了问题,而不需要再对minidump文件进行分析,总体来讲是提升了分析效率的:



      • 阶段一:有了java的调用栈和native的调用栈信息,大部分异常原因都可以快速定位并分析出来。

      • 阶段二:回调中也会提供minidump文件的存储路径,业务部门可以按需拉取。(这一步需要业务部门本身有拉取日志的功能,且需要按上文”现状部分进行操作”,较费时费力)





  3. 最少改动:

    • 让接入方不因为引入新功能而大量改动现有代码。例如:在native崩溃回调处,使用现有的java层异常监控系统上报native异常信息。



  4. 单一职责:

    • 只做native的crash捕获,不做系统内存情况、cpu使用率、系统日志等信息的采集功能。




整体流程


image.png


功能介绍



  • 保留breakpad导出minidump文件功能 (可选择是否启用)

  • 发生native异常时将异常信息、native层调用栈、java层的调用栈通过回调提供给开发者,将这些信息输出到控制台的效果如下:


2022-02-14 11:33:08.598 30228-30253/com.babyte.banativecrash E/crash:  
/data/user/0/com.babyte.banativecrash/cache/f1474006-60ca-40f4-c9d8e89a-47e90c2e.dmp
2022-02-14 11:33:08.599 30228-30253/com.babyte.banativecrash E/crash:
Operating system: Android 28 Linux 4.4.146 #37 SMP PREEMPT Wed Jan 20 18:26:59 CST 2021
CPU: aarch64 (8 core)

Crash reason: signal 11(SIGSEGV) Invalid address
Crash address: 0000000000000000
Crash pc: 0000000000000650
Crash so: /data/app/com.babyte.banativecrash-ptLzOQ_6UYz-W3Vgyact8A==/lib/arm64/libnative-lib.so(arm64)
Crash method: _Z5Crashv
2022-02-14 11:33:08.602 30228-30253/com.babyte.banativecrash E/crash:
Thread[name:DefaultDispatch] (NOTE: linux thread name length limit is 15 characters)
#00 pc 0000000000000650 /data/app/com.babyte.banativecrash-ptLzOQ_6UYz-W3Vgyact8A==/lib/arm64/libnative-lib.so (Crash()+20)
#01 pc 0000000000000670 /data/app/com.babyte.banativecrash-ptLzOQ_6UYz-W3Vgyact8A==/lib/arm64/libnative-lib.so (Java_com_babyte_banativecrash_MainActivity_nativeCrash+20)
#02 pc 0000000000565de0 /system/lib64/libart.so (offset 0xc1000) (art_quick_generic_jni_trampoline+144)
#03 pc 000000000055cd88 /system/lib64/libart.so (offset 0xc1000) (art_quick_invoke_stub+584)
#04 pc 00000000000cf740 /system/lib64/libart.so (art::ArtMethod::Invoke(art::Thread*, unsigned int*, unsigned int, art::JValue*, char const*)+200)
#05 pc 00000000002823b8 /system/lib64/libart.so (offset 0xc1000)
...
2022-02-14 11:33:08.603 30228-30253/com.babyte.banativecrash E/crash:
Thread[DefaultDispatcher-worker-1,5,main]
at com.babyte.banativecrash.MainActivity.nativeCrash(Native Method)
at com.babyte.banativecrash.MainActivity$onCreate$2$1.invokeSuspend(MainActivity.kt:39)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
Thread[DefaultDispatcher-worker-2,5,main]
at java.lang.Object.wait(Native Method)
at java.lang.Thread.parkFor$(Thread.java:2137)
at sun.misc.Unsafe.park(Unsafe.java:358)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:353)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.park(CoroutineScheduler.kt:795)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.tryPark(CoroutineScheduler.kt:740)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:711)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
...

定位到so中具体代码行示例


可以使用ndk中的add2line工具根据pc值和带符号信息的so库,定位出具体代码行数。


例:从上文的异常信息中可以看到abi是aarch64,对应的so库abi是arm64,所以add2line的使用如下:


$ ./ndk/android-ndk-r16b/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-addr2line -Cfe ~/arm64-v8a/libnative-lib.so 0000000000000650

输出结果如下:


Crash()
/Users/ba/AndroidStudioProjects/NativeCrash2Java/app/.cxx/cmake/debug/arm64-v8a/../../../../src/main/cpp/native-lib.cpp:6

接入方式


根项目的build.gradle中:


allprojects {
repositories {
mavenCentral()//添加这一行
}
}

模块的build.gradle中:


dependencies {   
//添加这一行,releaseVersionCode填最新的版本
implementation 'io.github.BAByte:native-crash:releaseVersionCode'
}

初始化


两种模式可选:


//发生native异常时:回调异常信息并导出minidump到指定目录,
BaByteBreakpad.initBreakpad(this.cacheDir.absolutePath) { info:CrashInfo ->
//格式化输出到控制台
BaByteBreakpad.formatPrint(TAG, info)
}

//发生native异常时:回调异常信息
BaByteBreakpad.initBreakpad { info:CrashInfo ->
//格式化输出到控制台
BaByteBreakpad.formatPrint(TAG, info)
}

示例项目


点击查看:示例项目


致谢



  • 感谢google breakpad库提供的源码

  • 感谢腾讯bugly团队提供在发生异常时,native回调java层的思路

  • 感谢爱奇艺xCrash库源码中的dlopen思路

作者:spider集控团队
链接:https://juejin.cn/post/7067052595331727390
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

不掌握这些坑,你敢用BigDecimal吗?

背景 一直从事金融相关项目,所以对BigDecimal再熟悉不过了,也曾看到很多同学因为不知道、不了解或使用不当导致资损事件发生。 所以,如果你从事金融相关项目,或者你的项目中涉及到金额的计算,那么你一定要花时间看看这篇文章,全面学习一下BigDecimal。...
继续阅读 »

背景


一直从事金融相关项目,所以对BigDecimal再熟悉不过了,也曾看到很多同学因为不知道、不了解或使用不当导致资损事件发生。


所以,如果你从事金融相关项目,或者你的项目中涉及到金额的计算,那么你一定要花时间看看这篇文章,全面学习一下BigDecimal。


BigDecimal概述


Java在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。双精度浮点型变量double可以处理16位有效数,但在实际应用中,可能需要对更大或者更小的数进行运算和处理。


一般情况下,对于不需要准确计算精度的数字,可以直接使用Float和Double处理,但是Double.valueOf(String) 和Float.valueOf(String)会丢失精度。所以如果需要精确计算的结果,则必须使用BigDecimal类来操作。


BigDecimal对象提供了传统的+、-、*、/等算术运算符对应的方法,通过这些方法进行相应的操作。BigDecimal都是不可变的(immutable)的, 在进行每一次四则运算时,都会产生一个新的对象 ,所以在做加减乘除运算时要记得要保存操作后的值。


BigDecimal的4个坑


在使用BigDecimal时,有4种使用场景下的坑,你一定要了解一下,如果使用不当,必定很惨。掌握这些案例,当别人写出有坑的代码,你也能够一眼识别出来,大牛就是这么练成的。


第一:浮点类型的坑


在学习了解BigDecimal的坑之前,先来说一个老生常谈的问题:如果使用Float、Double等浮点类型进行计算时,有可能得到的是一个近似值,而不是精确的值。


比如下面的代码:


  @Test
public void test0(){
float a = 1;
float b = 0.9f;
System.out.println(a - b);
}

结果是多少?0.1吗?不是,执行上面代码执行的结果是0.100000024。之所以产生这样的结果,是因为0.1的二进制表示是无限循环的。由于计算机的资源是有限的,所以是没办法用二进制精确的表示 0.1,只能用「近似值」来表示,就是在有限的精度情况下,最大化接近 0.1 的二进制数,于是就会造成精度缺失的情况


关于上述的现象大家都知道,不再详细展开。同时,还会得出结论在科学计数法时可考虑使用浮点类型,但如果是涉及到金额计算要使用BigDecimal来计算。


那么,BigDecimal就一定能避免上述的浮点问题吗?来看下面的示例:


  @Test
public void test1(){
BigDecimal a = new BigDecimal(0.01);
BigDecimal b = BigDecimal.valueOf(0.01);
System.out.println("a = " + a);
System.out.println("b = " + b);
}

上述单元测试中的代码,a和b结果分别是什么?


a = 0.01000000000000000020816681711721685132943093776702880859375
b = 0.01

上面的实例说明,即便是使用BigDecimal,结果依旧会出现精度问题。这就涉及到创建BigDecimal对象时,如果有初始值,是采用new BigDecimal的形式,还是通过BigDecimal#valueOf方法了。


之所以会出现上述现象,是因为new BigDecimal时,传入的0.1已经是浮点类型了,鉴于上面说的这个值只是近似值,在使用new BigDecimal时就把这个近似值完整的保留下来了。


而BigDecimal#valueOf则不同,它的源码实现如下:


    public static BigDecimal valueOf(double val) {
      // Reminder: a zero double returns '0.0', so we cannot fastpath
      // to use the constant ZERO. This might be important enough to
      // justify a factory approach, a cache, or a few private
      // constants, later.
      return new BigDecimal(Double.toString(val));
  }

在valueOf内部,使用Double#toString方法,将浮点类型的值转换成了字符串,因此就不存在精度丢失问题了。


此时就得出一个基本的结论:第一,在使用BigDecimal构造函数时,尽量传递字符串而非浮点类型;第二,如果无法满足第一条,则可采用BigDecimal#valueOf方法来构造初始化值


这里延伸一下,BigDecimal常见的构造方法有如下几种:


BigDecimal(int)       创建一个具有参数所指定整数值的对象。
BigDecimal(double)   创建一个具有参数所指定双精度值的对象。
BigDecimal(long)     创建一个具有参数所指定长整数值的对象。
BigDecimal(String)   创建一个具有参数所指定以字符串表示的数值的对象。

其中涉及到参数类型为double的构造方法,会出现上述的问题,使用时需特别留意。


第二:浮点精度的坑


如果比较两个BigDecimal的值是否相等,你会如何比较?使用equals方法还是compareTo方法呢?


先来看一个示例:


  @Test
public void test2(){
BigDecimal a = new BigDecimal("0.01");
BigDecimal b = new BigDecimal("0.010");
System.out.println(a.equals(b));
System.out.println(a.compareTo(b));
}

乍一看感觉可能相等,但实际上它们的本质并不相同。


equals方法是基于BigDecimal实现的equals方法来进行比较的,直观印象就是比较两个对象是否相同,那么代码是如何实现的呢?


    @Override
  public boolean equals(Object x) {
      if (!(x instanceof BigDecimal))
          return false;
      BigDecimal xDec = (BigDecimal) x;
      if (x == this)
          return true;
      if (scale != xDec.scale)
          return false;
      long s = this.intCompact;
      long xs = xDec.intCompact;
      if (s != INFLATED) {
          if (xs == INFLATED)
              xs = compactValFor(xDec.intVal);
          return xs == s;
      } else if (xs != INFLATED)
          return xs == compactValFor(this.intVal);

      return this.inflated().equals(xDec.inflated());
  }

仔细阅读代码可以看出,equals方法不仅比较了值是否相等,还比较了精度是否相同。上述示例中,由于两者的精度不同,所以equals方法的结果当然是false了。而compareTo方法实现了Comparable接口,真正比较的是值的大小,返回的值为-1(小于),0(等于),1(大于)。


基本结论:通常情况,如果比较两个BigDecimal值的大小,采用其实现的compareTo方法;如果严格限制精度的比较,那么则可考虑使用equals方法


另外,这种场景在比较0值的时候比较常见,比如比较BigDecimal("0")、BigDecimal("0.0")、BigDecimal("0.00"),此时一定要使用compareTo方法进行比较。


第三:设置精度的坑


在项目中看到好多同学通过BigDecimal进行计算时不设置计算结果的精度和舍入模式,真是着急人,虽然大多数情况下不会出现什么问题。但下面的场景就不一定了:


  @Test
public void test3(){
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
a.divide(b);
}

执行上述代码的结果是什么?ArithmeticException异常


java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.

at java.math.BigDecimal.divide(BigDecimal.java:1690)
...

这个异常的发生在官方文档中也有说明:


If the quotient has a nonterminating decimal expansion and the operation is specified to return an exact result, an ArithmeticException is thrown. Otherwise, the exact result of the division is returned, as done for other operations.


总结一下就是,如果在除法(divide)运算过程中,如果商是一个无限小数(0.333…),而操作的结果预期是一个精确的数字,那么将会抛出ArithmeticException异常。


此时,只需在使用divide方法时指定结果的精度即可:


  @Test
public void test3(){
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
BigDecimal c = a.divide(b, 2,RoundingMode.HALF_UP);
System.out.println(c);
}

执行上述代码,输入结果为0.33。


基本结论:在使用BigDecimal进行(所有)运算时,一定要明确指定精度和舍入模式


拓展一下,舍入模式定义在RoundingMode枚举类中,共有8种:



  • RoundingMode.UP:舍入远离零的舍入模式。在丢弃非零部分之前始终增加数字(始终对非零舍弃部分前面的数字加1)。注意,此舍入模式始终不会减少计算值的大小。

  • RoundingMode.DOWN:接近零的舍入模式。在丢弃某部分之前始终不增加数字(从不对舍弃部分前面的数字加1,即截短)。注意,此舍入模式始终不会增加计算值的大小。

  • RoundingMode.CEILING:接近正无穷大的舍入模式。如果 BigDecimal 为正,则舍入行为与 ROUNDUP 相同;如果为负,则舍入行为与 ROUNDDOWN 相同。注意,此舍入模式始终不会减少计算值。

  • RoundingMode.FLOOR:接近负无穷大的舍入模式。如果 BigDecimal 为正,则舍入行为与 ROUNDDOWN 相同;如果为负,则舍入行为与 ROUNDUP 相同。注意,此舍入模式始终不会增加计算值。

  • RoundingMode.HALF_UP:向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为向上舍入的舍入模式。如果舍弃部分 >= 0.5,则舍入行为与 ROUND_UP 相同;否则舍入行为与 ROUND_DOWN 相同。注意,这是我们在小学时学过的舍入模式(四舍五入)。

  • RoundingMode.HALF_DOWN:向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为上舍入的舍入模式。如果舍弃部分 > 0.5,则舍入行为与 ROUND_UP 相同;否则舍入行为与 ROUND_DOWN 相同(五舍六入)。

  • RoundingMode.HALF_EVEN:向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则向相邻的偶数舍入。如果舍弃部分左边的数字为奇数,则舍入行为与 ROUNDHALFUP 相同;如果为偶数,则舍入行为与 ROUNDHALF_DOWN 相同。注意,在重复进行一系列计算时,此舍入模式可以将累加错误减到最小。此舍入模式也称为“银行家舍入法”,主要在美国使用。四舍六入,五分两种情况。如果前一位为奇数,则入位,否则舍去。以下例子为保留小数点1位,那么这种舍入方式下的结果。1.15 ==> 1.2 ,1.25 ==> 1.2

  • RoundingMode.UNNECESSARY:断言请求的操作具有精确的结果,因此不需要舍入。如果对获得精确结果的操作指定此舍入模式,则抛出ArithmeticException。


通常我们使用的四舍五入即RoundingMode.HALF_UP。


第四:三种字符串输出的坑


当使用BigDecimal之后,需要转换成String类型,你是如何操作的?直接toString?


先来看看下面的代码:


@Test
public void test4(){
BigDecimal a = BigDecimal.valueOf(35634535255456719.22345634534124578902);
System.out.println(a.toString());
}

执行的结果是上述对应的值吗?并不是:


3.563453525545672E+16

也就是说,本来想打印字符串的,结果打印出来的是科学计数法的值。


这里我们需要了解BigDecimal转换字符串的三个方法



  • toPlainString():不使用任何科学计数法;

  • toString():在必要的时候使用科学计数法;

  • toEngineeringString() :在必要的时候使用工程计数法。类似于科学计数法,只不过指数的幂都是3的倍数,这样方便工程上的应用,因为在很多单位转换的时候都是10^3;


三种方法展示结果示例如下:


计算法


基本结论:根据数据结果展示格式不同,采用不同的字符串输出方法,通常使用比较多的方法为toPlainString()


另外,NumberFormat类的format()方法可以使用BigDecimal对象作为其参数,可以利用BigDecimal对超出16位有效数字的货币值,百分值,以及一般数值进行格式化控制。


使用示例如下:


NumberFormat currency = NumberFormat.getCurrencyInstance(); //建立货币格式化引用
NumberFormat percent = NumberFormat.getPercentInstance(); //建立百分比格式化引用
percent.setMaximumFractionDigits(3); //百分比小数点最多3位

BigDecimal loanAmount = new BigDecimal("15000.48"); //金额
BigDecimal interestRate = new BigDecimal("0.008"); //利率
BigDecimal interest = loanAmount.multiply(interestRate); //相乘

System.out.println("金额:\t" + currency.format(loanAmount));
System.out.println("利率:\t" + percent.format(interestRate));
System.out.println("利息:\t" + currency.format(interest));

输出结果如下:


金额: ¥15,000.48 
利率: 0.8%
利息: ¥120.00

小结


本篇文章介绍了BigDecimal使用中场景的坑,以及基于这些坑我们得出的“最佳实践”。虽然某些场景下推荐使用BigDecimal,它能够达到更好的精度,但性能相较于double和float,还是有一定的损失的,特别在处理庞大,复杂的运算时尤为明显。故一般精度的计算没必要使用BigDecimal。而必须使用时,一定要规避上述的坑。


作者:程序新视界
链接:https://juejin.cn/post/7121852516228136996
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter 组件集录 | 桌面导航 NavigationRail

我们都知道 BottomNavigationBar 是一个移动端非常常用的底部导航栏组件,可以用于点击处理激活菜单,并通过回调来处理界面的切换。 -- 但是在桌面端,由于一般是宽大于高,所以 BottomNavigationBar ...
继续阅读 »

我们都知道 BottomNavigationBar 是一个移动端非常常用的底部导航栏组件,可以用于点击处理激活菜单,并通过回调来处理界面的切换。















--



但是在桌面端,由于一般是宽大于高,所以 BottomNavigationBar 并不适用。而是侧边的导航栏较为常见,比如下面飞书的客户端界面布局。



为了满足桌面端的导航栏适用需求,官方新增了 NavigationRail 组件,而非对 BottomNavigationBar 组件进行适配。之前我也说过,对于差异较大的结构,并没有必要让一个组件通过适配来完成两端需求。分离开来也不是坏事,让一件衣服同时适配 蚂蚁燕子 是很困难的,这时做两件衣服,各司其职显然是更好地方式。


BottomNavigationBarNavigationRail 两个导航就是如此,从语义上来看 Bottom 就是用于底部的导航, Rail扶手铁轨 的意思,作为侧栏导航的语义,还是很生动有趣的。两者分别处理特定的结构,这也很符合 单一职责 的原则。


该组件已录入 【FlutterUnit】 ,可以在 App 中体验。另外,本文中的代码可在对应文件夹中查看:


image.png




1. NavigationRail 组件的基本使用

下面是 NavigationRail 组件的构造方法,其中必须传入的有两个参数:



  • destinations : 表示导航栏的信息,是 NavigationRailDestination 列表。

  • selectedIndex: 表示激活索引,int 类型。





我们先来实现如下最简单的使用场景,左侧导航栏,在点击时切换右侧内容页:



如果导航栏的数据是固定的,可以提前定义如下的 destinations 常量。如下的 _buildLeftNavigation 方法负责构建左侧导航栏,NavigationRail 在构造中可以通过 onDestinationSelected 回调方法,来监听用户和导航栏的交互事件,传递用点击的索引位置。


final List<NavigationRailDestination> destinations = const [
NavigationRailDestination(icon: Icon(Icons.message_outlined),label: Text("消息")),
NavigationRailDestination(icon: Icon(Icons.video_camera_back_outlined),label: Text("视频会议")),
NavigationRailDestination(icon: Icon(Icons.book_outlined),label: Text("通讯录")),
NavigationRailDestination(icon: Icon(Icons.cloud_upload_outlined),label: Text("云文档")),
NavigationRailDestination(icon: Icon(Icons.games_sharp),label: Text("工作台")),
NavigationRailDestination(icon: Icon(Icons.calendar_month),label: Text("日历"))
];

Widget _buildLeftNavigation(int index){
return NavigationRail(
onDestinationSelected: _onDestinationSelected,
destinations: destinations,
selectedIndex: index,
);
}

void _onDestinationSelected(int value) {
//TODO 更新索引 + 切换界面
}



NavigationRail 的文档注释中说道:该组件一般在 Row 中,使用于 Scaffold.body 属性下。这也很容易理解,这是一个左右结构,在 Row 中可以通过 Expanded 可以自动延伸主体内容。如下,主体内容界面通过 PageView 进行构建,其中的 TestContent 组件在实际使用中换成你的需求界面。


@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
_buildLeftNavigation(index),
Expanded(child: PageView(
children:const [
TestContent(content: '消息',),
TestContent(content: '视频会议',),
TestContent(content: '通讯录',),
TestContent(content: '云文档',),
TestContent(content: '工作台',),
TestContent(content: '日历',),
],
))
],
),
);
}



最后是关键的一点:点击时,如何实现导航索引的切换和主体内容的切页。思路其实很简单,我们已经知道用户点击导航菜单的回调事件。对于 PageView 来说,可以通过 PageController 切换界面,NavigationRail 可以通过 selectedIndex 确定激活索引,所以只要用新索引重新构建 NavigationRail即可。
如下代码所示,在 _onDestinationSelected 在处理这两件重要的事。如下 tag1 处,通过 PageControllerjumpToPage 方法进行界面跳转。


这里通过 ValueListenableBuilder 来监听 _selectIndex 实现局部更新构建,如下 tag2 处,只要更新 _selectIndex 的值,就可以通知 ValueListenableBuilder 触发 builder 方法,使用新索引,构建 NavigationRail 。这样可以避免直接触发 _MyHomePageState 的更新方法,对 Scaffold 整体进行更新。


class _MyHomePageState extends State<MyHomePage> {

final PageController _controller = PageController();
final ValueNotifier<int> _selectIndex = ValueNotifier(0);

// 略同...
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (_,index,__)=>_buildLeftNavigation(index),
),
Expanded(child: PageView(
controller: _controller,
// 略同...
}

void _onDestinationSelected(int value) {
_controller.jumpToPage(value); // tag1
_selectIndex.value = value; //tag2
}

@override
void dispose(){
_controller.dispose();
_selectIndex.dispose();
super.dispose();
}
}

这样就完成了 NavigationRail 最基本的使用,实现了左侧导航结构以及点击时的切换逻辑。NavigationRail 在构造方法中还有很多其他的配置参数用于样式调整,这些不是核心,但可以锦上添花,下面一起来看一下。




2.首尾组件与折叠

leadingtrailing 属性相当于两个插槽,如下所示,表示导航菜单外的首尾组件。



Widget _buildLeftNavigation(int index){
return NavigationRail(
leading: const Icon(Icons.menu_open,color: Colors.grey,),
trailing: FlutterLogo(),
onDestinationSelected: _onDestinationSelected,
destinations: destinations,
selectedIndex: index,
);
}



这里有个小细节,trailing 紧随最后一个菜单,如何让它像飞书的导航那样,在最尾部呢?偷瞄一些源码可以看出 trailing 是和导航菜单一起被放入 Column 中的。



所以我们可以通过 Expanded 来延伸剩余空间形成紧约束,通过 Align 使 FlutterLogo 排在下方:



Widget _buildLeftNavigation(int index){
return NavigationRail(
leading: const Icon(Icons.menu_open,color: Colors.grey,),
extended: false,
trailing: const Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 20.0),
child: FlutterLogo(),
),
),
),
onDestinationSelected: _onDestinationSelected,
destinations: destinations,
selectedIndex: index,
);
}



另外,NavigationRail 中有个 extendedbool 参数,用于控制是否展开侧边栏,当该属性变化时,会进行动画展开和收起。如下所示,点击头部时,更新 NavigationRailextended 入参即可:





3.影深 与 标签类型

elevation 表示阴影的深度,这是非常常见的一个属性,如下红框所示,设置 elevation 之后右侧会有阴影,该值越大,阴影越明显。





labelType 参数表示标签类型,对应的属性是 NavigationRailLabelType 枚举。用于表示什么时候显示文字标签,默认是 none ,也就是只显示图标,没有文字。


enum NavigationRailLabelType {
none,
selected,
all,
}

设置为 all 时,效果如下:导航菜单会同时显示 图标文字标签





设置为 selected 时,效果如下:只有激活的导航菜单会同时显示 图标文字标签



另外,有一点需要注意: 当 extended 属性为 true 时, labelType 必须为 NavigationRailLabelType.none 不然会报错。



---->[NavigationRail构造断言]----
assert(!extended || (labelType == null || labelType == NavigationRailLabelType.none)),



4.背景、文字、图标样式


  • unselectedLabelTextStyle : 未选中签文字样式

  • selectedLabelTextStyle : 选中标签文字样式

  • unselectedIconTheme : 未选中图标样式

  • selectedIconTheme : 选中图标样式


这四个样式基本上是顾名思义,下面通过一个深色背景版本来使用一下:



@override
Widget build(BuildContext context) {
const Color textColor = Color(0xffcfd1d7);
const Color activeColor = Colors.blue;
const TextStyle labelStyle = TextStyle(color: textColor,fontSize: 11);

return NavigationRail(
backgroundColor: const Color(0xff324465),
unselectedIconTheme: const IconThemeData(color: textColor) ,
selectedIconTheme: const IconThemeData(color: activeColor) ,
unselectedLabelTextStyle: labelStyle,
selectedLabelTextStyle: labelStyle,
// 略同...
}



5.指示器与最小宽度


  • useIndicator : 是否添加指示器

  • indicatorColor : 指示器颜色


这两个属性用于控制图标后面的背景指示器,如下是在 NavigationRailLabelType.all 类型下指示器的样式,通过圆角矩形包裹图标:





NavigationRailLabelType.none 类型下,指示器通过圆形包裹图标:






  • minWidth : 默认 72 ,未展开时导航栏宽度




  • indicatorColor :默认 256 ,展开时导航栏宽度



NavigationRail 组件的属性介绍就到这里,总的来看,悬浮和点击时,导航栏还是一股 Material 的味。个人觉得这并不适合桌面端,导航栏的菜单可定制性也一般般,只能满足基本的需求。对于稍微特别点的样式,无法支持,比如飞书客户端的导航样式。另外像 拖动更换菜单位置 这样的交互,我们也只通过自定义组件来实现。





6.剖析 NavigationRail 组件,借鉴思路

就像世界上并没有什么包治百病的 ,我们也并不能苛求一个组件能满足所有的布局需求。对于一个原生组件满足不了的需求,发挥创造能力去解决问题,这应是我们的本职工作。借鉴官方对于组件实现的思路是非常重要的,它可以为你提供一个主方向。



我们可以发现 NavigationRailSwitchBottomNavigationBar 等组件一样,虽然自身是 StatefulWidget, 但对于激活状态的数据并不是在内部状态中维护,而是让 使用者主动提供,比如这里在构造 NavigationRail 时必须传入 selectedIndex 。 该组件只提供回调事件来通知使用者,这样的用意是让使用者更容易 控制 该状态,而不是完全封装在状态类内部。


另外,从 selectedIndex 属性在状态类中的使用中可以看出,每个菜单的条目组件通过 _RailDestination 进行构建。从这里可以看出,_RailDestination 会通过 selected 属性来区分是否激活,而且会通过 onTap 回调点击事件。在此触发 widget.onDestinationSelected ,将当前索引 i 传递给用户。



这里 _RailDestinationStatelessWidget, 只说明并不需要维护内部状态的变化,组需要根据构造中的配置信息构建需要的组件即可。这就尽可能地简化了 _RailDestination 的构建逻辑,让其相对独立,专注地去做一件事。这就是组件分离的好处之一:既可以简化构建结构,增加可读性,又可以将相对独立的构建逻辑内聚在一起。我们完全可以在日常开发中对这样的分离进行借鉴和发挥。




另外这里比较值得借鉴的还有动画的处理,我看了一下目前桌面的一些应用,比如 微信飞书有道词典百度网盘AndroidStudio有道云笔记 ,这些导航栏在切换时都是没有动画的。如下所示,NavigationRail 对应的状态类中维护了两种动画控制器,这也是 NavigationRail 为什么需要是 StatefulWidget 的原因。



其中 _destinationControllers 用于处理,菜单背景指示器在点击时激活/非激活的透明度渐变动画。可以追踪一下动画器的去向: 在 NavigationIndicator 中通过 FadeTransition使用动画器完成透明度渐变动画。


_RailDestination -->  _AddIndicator --> NavigationIndicator
复制代码




最后看一下 _extendedController 动画控制器,它对应的动画器也被传入 _RailDestination 中来完成动画功能。这个动画控制器在 extended 属性变化时,展开折叠导航栏的动画。如下源码所示,可以看出关于这个动画更多的细节。 动画过程中文字标签有个透明度渐变的动画,宽度约束通过对 ConstrainedBox 进行限制,并通过 AlignwidthFactor 控制文字标签区域的尺寸。



这里的 ClipRect 组件套的很迷,我试了一下去除后并不影响动画效果,一开始不知道为什么要加。之后将动画时长拉长,进行了一些测试发现端倪,如果不进行裁剪,就会出现如下的不和谐情况。默认动画 200 ms 看不出太大差异。从这里我又学到了一个小技巧:如何动画展开一个区域。



所以说源码是最好的老师,通过分析源码的实现去思考和学习,是成长的一条很好的途径。而不是什么东西都靠别人给你灌输,遇到不会的或犹豫不决时就到处问。Flutter 组件的源码相对独立,套路也比较简单,很适合去研究学习。《Flutter 组件集录》 专栏专门用于收录我对 Flutter 常用组件的使用介绍,其中一般也会有相关源码实现的一些分析。对一些能力稍弱的朋友,也可以根据这些介绍去尝试研究。那本文就到这里,谢谢观看 ~


作者:张风捷特烈
链接:https://juejin.cn/post/7124097837389840397
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

公司产品太多了,怎么实现一次登录产品互通?

大家好,我是老王,最近开发新产品,然后老板说我们现在系统太多了,每次切换系统登录太麻烦了,能不能做个优化,同一账号互通掉。作为一个资深架构狮,老板的要求肯定要满足,安排! 一个公司产品矩阵比较丰富的时候,用户在不同系统之间来回切换,固然对产品用户体验上较差,...
继续阅读 »

大家好,我是老王,最近开发新产品,然后老板说我们现在系统太多了,每次切换系统登录太麻烦了,能不能做个优化,同一账号互通掉。作为一个资深架构狮,老板的要求肯定要满足,安排!


image.png


一个公司产品矩阵比较丰富的时候,用户在不同系统之间来回切换,固然对产品用户体验上较差,并且增加用户密码管理成本。也没有很好地利用内部流量进行用户打通,并且每个产品的独立体系会导致产品安全度下降。因此实现集团产品的单点登录对用户使用体验以及效率提升有很大的帮助。那么如何实现统一认证呢?我们先了解一下传统的身份验证方式。


1 传统Session机制及身份认证方案


1.1 Cookie与服务器的交互


image.png


众所周知,http是无状态的协议,因此客户每次通过浏览器访问web

页面,请求到服务端时,服务器都会新建线程,打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一个电商内的购物车功能,要怎么才能知道哪些购物车请求对应的是来自同一个客户的请求呢?


image.png


因此出现了session这个概念,session 就是一种保存上下文信息的机制,他是面向用户的,每一个SessionID 对应着一个用户,并且保存在服务端中。session主要 以 cookie 或 URL 重写为基础的来实现的,默认使用 cookie 来实现,系统会创造一个名为JSESSIONID的变量输出到cookie中。


JSESSIONID 是存储于浏览器内存中的,并不是写到硬盘上的,如果我们把浏览器的cookie 禁止,则 web 服务器会采用 URL 重写的方式传递 Sessionid,我们就可以在地址栏看到 sessionid=KWJHUG6JJM65HS2K6 之类的字符串。


通常 JSESSIONID 是不能跨窗口使用的,当你新开了一个浏览器窗口进入相同页面时,系统会赋予你一个新的sessionid,这样我们信息共享的目的就达不到了。


1.2 服务器端的session的机制


当服务端收到客户端的请求时候,首先判断请求里是否包含了JSESSIONID的sessionId,如果存在说明已经创建过了,直接从内存中拿出来使用,如果查询不到,说明是无效的。


如果客户请求不包含sessionid,则为此客户创建一个session并且生成一个与此session相关联的sessionid,这个sessionid将在本次响应中返回给客户端保存。


对每次http请求,都经历以下步骤处理:


-服务端首先查找对应的cookie的值(sessionid)。

-根据sessionid,从服务器端session存储中获取对应id的session数据,进行返回。

-如果找不到sessionid,服务器端就创建session,生成sessionid对应的cookie,写入到响应头中。


session是由服务端生成的,并且以散列表的形式保存在内存中


1.3 基于 session 的身份认证流程


基于seesion的身份认证主要流程如下:


image.png


因为 http 请求是无状态请求,所以在 Web 领域,大部分都是通过这种方式解决。但是这么做有什么问题呢?我们接着看


2 集群环境下的 Session 困境及解决方案


image.png


随着技术的发展,用户流量增大,单个服务器已经不能满足系统的需要了,分布式架构开始流行。通常都会把系统部署在多台服务器上,通过负载均衡把请求分发到其中的一台服务器上,这样很可能同一个用户的请求被分发到不同的服务器上,因为 session 是保存在服务器上的,那么很有可能第一次请求访问的 A 服务器,创建了 session,但是第二次访问到了 B 服务器,这时就会出现取不到 session 的情况。


我们知道,Session 一般是用来存会话全局的用户信息(不仅仅是登陆方面的问题),用来简化/加速后续的业务请求。

传统的 session 由服务器端生成并存储,当应用进行分布式集群部署的时候,如何保证不同服务器上 session 信息能够共享呢?


2.1 Session共享方案


Session共享一般有两种思路



  • session复制

  • session集中存储


2.1.1 session复制


session复制即将不同服务器上 session 数据进行复制,用户登录,修改,注销时,将session信息同时也复制到其他机器上面去

image.png


这种实现的问题就是实现成本高,维护难度大,并且会存在延迟登问题。


2.1.2 session集中存储


image.png


集中存储就是将获取session单独放在一个服务中进行存储,所有获取session的统一来这个服务中去取。这样就避免了同步和维护多套session的问题。一般我们都是使用redis进行集中式存储session。


3 多服务下的登陆困境及SSO方案


3.1 SSO的产生背景


image.png


如果企业做大了之后,一般都有很多的业务支持系统为其提供相应的管理和 IT 服务,按照传统的验证方式访问多系统,每个单独的系统都会有自己的安全体系和身份认证系统。进入每个系统都需要进行登录,获取session,再通过session访问对应系统资源。这样的局面不仅给管理上带来了很大的困难,对客户来说也极不友好,那么如何让客户只需登陆一次,就可以进入多个系统,而不需要重新登录呢?


image.png


“单点登录”就是专为解决此类问题的。 其大致思想流程如下:通过一个 ticket 进行串接各系统间的用户信息


3.2 SSO的底层原理 CAS


3.2.1 CAS实现单点登录流程


我们知道对于完全不同域名的系统,cookie 是无法跨域名共享的,因此 sessionId 在页面端也无法共享,因此需要实现单店登录,就需要启用一个专门用来登录的域名如(ouath.com)来提供所有系统的sessionId。当业务系统被打开时,借助中心授权系统进行登录,整体流程如下:


1.当b.com打开时,发现自己未登陆,于是跳转到ouath.com去登陆

2. ouath.com登陆页面被打开,用户输入帐户/密码登陆成功

3. ouath.com登陆成功,种 cookie 到ouath.com域名下

4. 把 sessionid 放入后台redis,存放<ticket,sesssionid>数据结构,然后页面重定向到A系统

5.当b.com重新被打开,发现仍然是未登陆,但是有了一个 ticket值

6. 当b.com用ticket 值,到 redis 里查到 sessionid,并做 session 同步,然后种cookie给自己,页面原地重定向

7. 当b.com打开自己页面,此时有了 cookie,后台校验登陆状态,成功


整个交互流程图如下:


image.png


3.2.2 单点登录流程演示


3.2.2.1 CAS登录服务demo核心代码


1.用户实体类



public class UserForm implements Serializable{
private static final long serialVersionUID = 1L;

private String username;
private String password;
private String backurl;

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}

public String getBackurl() {
return backurl;
}

public void setBackurl(String backurl) {
this.backurl = backurl;
}

}

2.登录控制器


@Controller
public class IndexController {
@Autowired
private RedisTemplate redisTemplate;

@GetMapping("/toLogin")
public String toLogin(Model model,HttpServletRequest request) {
Object userInfo = request.getSession().getAttribute(LoginFilter.USER_INFO);
//不为空,则是已登陆状态
if (null != userInfo){
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,userInfo,2, TimeUnit.SECONDS);
return "redirect:"+request.getParameter("url")+"?ticket="+ticket;
}
UserForm user = new UserForm();
user.setUsername("laowang");
user.setPassword("laowang");
user.setBackurl(request.getParameter("url"));
model.addAttribute("user", user);

return "login";
}

@PostMapping("/login")
public void login(@ModelAttribute UserForm user,HttpServletRequest request,HttpServletResponse response) throws IOException, ServletException {
System.out.println("backurl:"+user.getBackurl());
request.getSession().setAttribute(LoginFilter.USER_INFO,user);

//登陆成功,创建用户信息票据
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,user,20, TimeUnit.SECONDS);
//重定向,回原url ---a.com
if (null == user.getBackurl() || user.getBackurl().length()==0){
response.sendRedirect("/index");
} else {
response.sendRedirect(user.getBackurl()+"?ticket="+ticket);
}
}

@GetMapping("/index")
public ModelAndView index(HttpServletRequest request) {
ModelAndView modelAndView = new ModelAndView();
Object user = request.getSession().getAttribute(LoginFilter.USER_INFO);
UserForm userInfo = (UserForm) user;
modelAndView.setViewName("index");
modelAndView.addObject("user", userInfo);
request.getSession().setAttribute("test","123");
return modelAndView;
}
}

3.登录过滤器


public class LoginFilter implements Filter {
public static final String USER_INFO = "user";
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {

HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;

Object userInfo = request.getSession().getAttribute(USER_INFO);;

//如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&amp;&amp; !requestUrl.startsWith("/login")//不是去登陆
&amp;&amp; null == userInfo) {//不是登陆状态

request.getRequestDispatcher("/toLogin").forward(request,response);
return ;
}

filterChain.doFilter(request,servletResponse);
}

@Override
public void destroy() {

}
}

4.配置过滤器


@Configuration
public class LoginConfig {

//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration() {

FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new LoginFilter());
registration.addUrlPatterns("/*");
registration.addInitParameter("paramName", "paramValue");
registration.setName("sessionFilter");
registration.setOrder(1);
return registration;
}
}

5.登录页面


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>enjoy login</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div text-align="center">
<h1>请登陆</h1>
<form action="#" th:action="@{/login}" th:object="${user}" method="post">
<p>用户名: <input type="text" th:field="*{username}" /></p>
<p>密 码: <input type="text" th:field="*{password}" /></p>
<p><input type="submit" value="Submit" /> <input type="reset" value="Reset" /></p>
<input type="text" th:field="*{backurl}" hidden="hidden" />
</form>
</div>


</body>
</html>

3.2.2.2 web系统demo核心代码


1.过滤器


public class SSOFilter implements Filter {
private RedisTemplate redisTemplate;

public static final String USER_INFO = "user";

public SSOFilter(RedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {

HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;

Object userInfo = request.getSession().getAttribute(USER_INFO);;

//如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&amp;&amp; !requestUrl.startsWith("/login")//不是去登陆
&amp;&amp; null == userInfo) {//不是登陆状态

String ticket = request.getParameter("ticket");
//有票据,则使用票据去尝试拿取用户信息
if (null != ticket){
userInfo = redisTemplate.opsForValue().get(ticket);
}
//无法得到用户信息,则去登陆页面
if (null == userInfo){
response.sendRedirect("http://127.0.0.1:8080/toLogin?url="+request.getRequestURL().toString());
return ;
}

/**
* 将用户信息,加载进session中
*/
UserForm user = (UserForm) userInfo;
request.getSession().setAttribute(SSOFilter.USER_INFO,user);
redisTemplate.delete(ticket);
}

filterChain.doFilter(request,servletResponse);
}

@Override
public void destroy() {

}
}

2.控制器


@Controller
public class IndexController {
@Autowired
private RedisTemplate redisTemplate;

@GetMapping("/index")
public ModelAndView index(HttpServletRequest request) {
ModelAndView modelAndView = new ModelAndView();
Object userInfo = request.getSession().getAttribute(SSOFilter.USER_INFO);
UserForm user = (UserForm) userInfo;
modelAndView.setViewName("index");
modelAndView.addObject("user", user);

request.getSession().setAttribute("test","123");
return modelAndView;
}
}

3.首页


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>enjoy index</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div th:object="${user}">
<h1>cas-website:欢迎你"></h1>
</div>
</body>
</html>

3.2.3 CAS的单点登录和OAuth2的区别


OAuth2:三方授权协议,允许用户在不提供账号密码的情况下,通过信任的应用进行授权,使其客户端可以访问权限范围内的资源。


CAS :中央认证服务(Central Authentication Service),一个基于Kerberos票据方式实现SSO单点登录的框架,为Web 应用系统提供一种可靠的单点登录解决方法(属于 Web SSO )。



  1. CAS的单点登录时保障客户端的用户资源的安全 ;OAuth2则是保障服务端的用户资源的安全 。

  2. CAS客户端要获取的最终信息是,这个用户到底有没有权限访问我(CAS客户端)的资源;OAuth2获取的最终信息是,我(oauth2服务提供方)的用户的资源到底能不能让你(oauth2的客户端)访问。


因此,需要统一的账号密码进行身份认证,用CAS;需要授权第三方服务使用我方资源,使用OAuth2;


好了,不知道大家对SSO是否有了更深刻的理解,大家有问题可以私信我。我是王老狮,一个有想法有内涵的工程狮,关注我,学习更多技术知识。


作者:王老狮
链接:https://juejin.cn/post/7123787027652280356
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

有哪些话一听就知道一个程序员是个水货?

要判断一个程序员是水货是非常难的。反过来说,要面试出一个程序员的真实水平是比较困难的。现在很多小公司面试,动不动就是手写代码。从框架问到具体的技术实现细节,结果错过了好多真正有能力的程序员。反而,大公司的面试就正常多了,也对,大公司已经通过简历筛选,把不入流的...
继续阅读 »

要判断一个程序员是水货是非常难的。反过来说,要面试出一个程序员的真实水平是比较困难的。

现在很多小公司面试,动不动就是手写代码。从框架问到具体的技术实现细节,结果错过了好多真正有能力的程序员。反而,大公司的面试就正常多了,也对,大公司已经通过简历筛选,把不入流的大学筛出去了。

实际上,我认为程序员面试应该只关注两方面的能力。代码能力和工程能力。二者有其一即可。

代码能力。这个不用多说,程序员招进去就是写代码的,所以很多公司会有上机或者直接让手写代码这一招,题目不用多难,就是基础的链表、数组、树就能解决的算法,给支笔能写个90%出来,招进去,这名同学便不会太差。所以各大公司都很欢迎ACM选手,这部分人受过严格的训练,再差也不会差到哪儿去。

工程能力。只要问清楚做了什么?为什么要这样做?还有没有更好的办法?这几个问题基本上就能判断出这个人的项目经历的真假。如果项目经历为真,对于项目实现有自己的思考,招进去也不会太差。如果用到了自己熟悉的框架,可以抓住细节再聊一下。

上述说的这两个能力其实都不太容易造假。反而,如果招安卓app开发,抓住相关的知识点,一顿猛问,太容易被培训班之流靠背题糊弄过去。也就是说,面试的时候要抓住一些编程需要的通用能力,通用技能扎实的同学,给点时间,上手其他的也很快。

下面,歪个题。说下我的两次失败的面试经历,两次面的都是头部互联网大厂。

先说A大厂。下文直接进入面试环节。

面试官:你对C++比较熟悉,对吧?

我:是的。

面试官:那请问子类与父类之间如何实现多态。

我:用一张表来实现。

面试官很疑惑的样子:什么表!?

我:这张表来保存函数的入口地址,子类如果重写了某个函数,那么同名函数的入口地址就用子类的函数地址来替换,这样。

面试官打断了我:你听说个虚函数吗?

我:听说过。

面试官:好了,我没有问题了。你回去等通知吧。

这位大厂的面试官铁定认为我是一个水货,连C++的基本概念都掌握不清楚,就是来浑水摸鱼的。实际上,按照我的理解,大厂面试官不会问这种C++的基本概念,应该问的是虚函数如何实现等比较深的背景,那段时间我刚好看完了C++之父写的《C++语言的设计和演化
》,凭着理解说了一通,但是面试官认为我在瞎说,妥妥的水货。实际上,我当时可能说成虚函数表就没有问题了。当然,我也没有等到通知,从酒店下楼出来喝了杯奶茶以后,就发现“面试未通过”。

再说下B大厂,该大厂我笔试获得了非常高的分数。下文直接讲面试环节。

面试官:讲一讲你这个项目中类是怎么划分的。

我:我用的是MFC,这个框架用了MVC模型。所以我对于需要展示处理效果的代码放在了xxxview类里面,讲计算相关的代码放到了xxxmodel类里面了。

面试官一头雾水的样子:也就是说用别人的框架,你就加点处理代码就行了。

我:差不多是这样。

然后,我又回去等通知了。实际上,这个项目完完全全是由我自己独立完成,并且前后重写了两遍,中间的思考和重构是非常值得说的。如果我当时的回答变一下,先讲一下项目的背景,具体需要实现的功能,功能之间如何解耦
,使用了xx设计模式来提供扩展和需求变化。按照这个思路讲一下去,肯定会得到一个比较好的面试评价。

上述两个经历,就是我第一、第二次面试大厂出的岔子。本质上,还是自己没明白别人在问什么,第一个问题以为别人问得很深,实际上很浅。第二个问题,以为别人随便问问,实际上是在判断这个项目是否造假。所以,看一下别人的面经还是很有用的,也不会白白浪费机会。

再后来,我靠上机满分顺利去了H厂,连续多次拿了绩效A。工作中也遇到了好些个水货程序员,有那种简历上SCI一作,入职后写个for循环用了一天的。也有那种一千行代码30+bug的,还有那种自己代码出bug,看着屏幕只会干瞪眼的。但是这些人的工资都比我高。

来源:blog.csdn.net/qq_66238169/article/details/124886138

收起阅读 »

社区纠纷不断:程序员何苦为难程序员

今年年初,我们报道“因为被多人侮辱大吼,Swift 之父正式退出 Swift 核心团队”。诸如此类的“语言暴力”、“网络暴力”事件在开源社区乃至整个 IT 社区屡见不鲜。多个技术社区,都出现过创始人、重要维护者、贡献者因为感觉“社区氛围糟糕”、“受到伤害”而宣...
继续阅读 »

今年年初,我们报道“因为被多人侮辱大吼,Swift 之父正式退出 Swift 核心团队”。

诸如此类的“语言暴力”、“网络暴力”事件在开源社区乃至整个 IT 社区屡见不鲜。多个技术社区,都出现过创始人、重要维护者、贡献者因为感觉“社区氛围糟糕”、“受到伤害”而宣布退出的现象。更有甚者,还有科技公司领导被爆出叫嚣着让 80 后退出 IT 圈。后者可粗略视为是该领导过于偏激,且是少数案例,先不做过多讨论。但是在开源圈,怎么就这样了呢?

为了回答这个问题,本文梳理了以往的一些社区纠纷事件,发现许多矛盾发生在社区实际的管理者/层,与社区贡献者、参与者之间,双方的不满大致也可以归总成几类原因。


众口难调

从大教堂的开发模式转向集市开发模式之后,技术社区存在的目的便是为了聚众人之力,让项目更好。在集市模式下,开源社区给了个人极大的自由度,所有贡献者都可以畅所欲言,发表自己的想法,为项目作出贡献。随之而来的问题,便是如何协调所有人的意见。

社区的管理模也在一定程度上决定了社区日后的争吵风险有多大。当下的开源社区管理主要分成四种模式。一是由社区主导,采用自由贡献模式,“共识”是其前提,功能开发和版本发布等重要决策以社区共识为准。二是公司主导,由公司资助社区及软件的发展,相对来说,公司会拥有更多的实权。三是 BDFL 仁慈的独裁者模式,这是在社区模式的基础之上,社区中有一个“独裁者”的角色存在,他对一些重要决策有最终决定权,而非依赖社区共识。四是精英治理,在社区中表现突出、贡献最大的人被任命为管理团队,决策基于投票确定。

我们常见的纠纷事件,往往可以归总为“掌权者”和“贡献者”之争,这种争议更容易出现在“仁慈的独裁者”模式的开源社区中。其他三种模式中,许多纠纷的出现也是因为实际管理团队未扮演好自己的角色。

掌权者的不满

闻名世界的大型开源项目往往是某个“天才程序员”的作品,早期的开源社区一般都是“仁慈的独裁者”模式,独裁者饱受敬仰,比如 Linux 社区的 Linus、Ubuntu 社区、Python 社区。然而,天才似乎更乐于单兵作战。

  • 对贡献不满

暴躁大佬 Linus 在评价那些没达到他个人标准的代码方面非常毒舌。曾有网友用了此前 Linus 在邮件列表中公开的一段回复,直指 Linus 在人际沟通中态度恶劣:“这也算是一个 BUG?你已经成为内核维护者多长时间了?还没有学会内核维护的第一条规则?我再也不想收到这种明显的垃圾,像白痴一样的提交…… ”

对于一直看不爽的 Intel,Linus 对其提交的代码也是口吐芬芳。2018 年初,为了修补 Spectre 漏洞,Intel 工程师提供了一个间接分支限制推测(indirect branch restricted speculation, IBRS)功能的补丁。Linus 当时就在邮件列表中公开指出 IBRS 会造成系统性能大幅降低,直言该补丁“就是彻彻底底的垃圾”。


不仅仅是 Linus,在崇尚“精英主义”的 BSD 社区,也存在明显的“鄙视链”。BSD 的第一版撰写者 Bill Joy 不愿意相信庞大的志愿贡献者者们,他曾说:“大多数人都是糟糕的程序员,让很多人盯着代码不会真正发现错误。真正的错误是由几个非常聪明的人发现的。大多数人看代码不会看到任何东西......不能期望成千上万的人做出贡献并都达到高标准。”

这种看法一直存在于 BSD 之后的发展中,FreeBSD 深度参与者 Marshall Kirk McKusick 就曾表示,90% 的 committers 所贡献的代码都不能用,还剩下的一小部分也需要被打磨。

  • 遭遇信任危机

在 Python 社区,很多年内,Python 之父 Guido van Rossum 都是最有威信的那个人,他也被社区授予“终身仁慈的独裁者”的称谓。但在 2018 年,当 Python 社区探讨改进提案时,Guido 发现“现在有这么多人鄙视我的决定”。

因此,他不想再为 PEP(Python 改进提案)[ PEP 572 ] (https://www.python.org/dev/peps/pep-0572/)争取什么,并决定自己将逐步脱离决策层,不再领导 Python 的发展,休息一段时间后将作为普通的核心开发者参与社区。


有时,“仁慈的独裁者”也会因为“不作为”被指责。Ubuntu 创始人 Mark Shuttleworth 在社区中被戏称为“自封的仁慈独裁者”。事实上,Ubuntu 社区早期也确实是“仁慈的独裁者”管理模式。

然而,在 Ubuntu 社区蓬勃发展,各项业务步入正轨之际,Mark Shuttleworth 本人的工作逐渐与开发者拉开距离,Jono Bacon 等一些早期在 Ubuntu 社区中颇具名望的核心成员相继离开,Mark Shuttleworth 也很少在社区活跃。逐渐,有一些资深的 Ubuntu 开发者认为社区正面临“群龙无首”的尴尬局面,指责沙特尔沃思没有尽到“BDFL”的责任。一位前 Ubuntu 开发人员在 Ubuntu 社区中留言指责 Mark Shuttleworth 作为 BDFL “放弃了社区,对社区治理的崩溃保持沉默”,令人失望。

Mark Shuttleworth 面对职责也欣然接受,承认自己在这些方面确实做得不够好,并表达了“挫败感”。

  • 没有回馈

开源项目最容易让人不满的点还当属“白嫖”,许多开源项目都是靠作者用爱发电,社区的汲取大于回馈,从而导致软件作者身心俱疲。

前段时间轰轰烈烈的 Apache Log4j2 高危漏洞事件,攻击者可以利用其 JNDI 注入漏洞远程执行代码,影响了众多项目和公司。此事也让 Log4j2 的作者受到关注与批评,Log4j2 的维护者之一 @Volkan Yazıcı 在推特上吐槽:Log4j2 维护者只有几个人,他们无偿、自愿地工作,没有人发工资,也没人提交代码修复问题,出了问题还要被一堆人在仓库里留言痛骂。

此前,PhantomJS 的核心开发者之一 Vitaly Slobodin 宣布辞任 maintainer ,不再维护项目,原因也是一个人发电太累:“我看不到 PhantomJS 的未来,作为一个单独的开发者去开发 PhantomJS 2 和 2.5 ,简直就像是一个血腥的地狱。即便是最近发布的 2.5 Beta 版本拥有全新、亮眼的 QtWebKit ,但我依然无法做到真正的支持 3 个平台。我们没有得到其他力量的支持!”

贡献者的不满

当社区随着项目生命周期的发展逐渐发生变化时,流程和规定上的改变不可避免,贡献者间交流的氛围也在不断变化中。贡献者对社区的不满往往是从社区发生变化开始……

  • 对项目或社区不再看好

一位 Debian 的包维护者 Stapelberg 在 2019 年写了篇长文宣布自己要退出 Debian 的开发流程,逐渐减少参与 Debian 的维护和相关活动。主要原因便在于,他发现 Debian 整个开发评估流程非常迟钝。

举例来说,补丁的评估没有截止日期,有时候他会收到通知说几年前递交的补丁现在合并了。Debian 的一些维护者出于个人喜好拒绝合作,维护者给予的个人自由度太高对 Debian 构成了严重影响。

在他对 Debian 的开发流程沮丧程度超过阈值之后,他便宣布退出,写了文章,希望能激励 Debian 作出改变。

在 LLVM 社区,2018 年一位名叫 Rafael Avila de Espindola 的资深开发者(LLVM 编译器贡献排名第五)发邮件宣布决定与该项目分道扬镳。 原因在于近几年的感受发生了变化,LLVM 日益庞大且变化缓慢,他也不赞成 LLVM 最近引入的社区行为规范。真正促使他做决定的是 LLVM 与一个公开根据性别和血统进行歧视的组织 Outreachy 进行合作,这让他感到非常不满。

除此之外,一些由公司主导的开源项目也会因为改变发展战略招致不满。

Qt 公司从 2021 年 1 月开始,将 Qt 5.15 作为仅供商业化的 LTS,现有的 Qt 5.15 分支将公开可见,但不会看到任何新补丁,只有付费账户才可以使用长期支持版本的 Qt 5.15 。

此举引起了社区的强烈不满,基于 Qt 开发的 KDE 社区的担忧。有用户在 Qt 官方公告下留言讽刺道:“所以,基本上您是在告诉所有忠实的开源用户,现在他们将仅被视为商业客户的 beta 测试者,并且作为奖励,他们将只能下载非 LTS 版本。你们真是太亲切了!”一位长期的 Qt 贡献者,来自英特尔公司的开发者 Thiago Macieira 表示,至少对于他在 Qt 中处理过的代码,他不会再参与修复、评论和审查后端错误报告。

  • 反独裁

与每个社区都有一个人或者几个人的实际管理团队向对应的是,在管理不当时,贡献者会起身反抗“管理”。

几个月前,Rust 审核团队 (Moderation Team) 昨日公告称,他们已集体辞职且即刻生效。团队成员 Andrew Gallant 表示此举是为了抗议 Rust 核心团队 (Core Team) 不对除自己以外的任何人负责。

Rust 的管理者分为很多个团队,比如核心团队、审核团队、发行团队、库管理团队等等...其中,Rust 核心团队的权限最大,而且其他团队无法影响他们。Andrew Gallant 在公告中写道,由于核心团队在组织结构层面的不负责任,他们一直无法按照社区对审核团队的期望和他们自己坚持的标准来执行 Rust 行为准则。对于在这种情况下选择离开,他们表达了对大家的歉意。但从治理 Rust 的角度来看,他们别无选择。因此 Rust 审核团队认为除了辞职和发表这份声明之外,已经没有其他办法了。


如何让众人满意?

矛盾激化的后果只有一个:成员慢慢离开。如果社区和项目不做出改变,则很有可能导致项目走向衰落。既然长时间充斥着争吵与不满的社区难以持久,那么,该如何构建一个让更多人愉快参与的社区?

首先,无论何种治理模式下的社区,个人文明表达观点,遵守社区行为准则都是减少冲突的必要条件。但是,个人行为是非常不稳定的因素,就像 Linus 曾说要改改自己的暴脾气,却每每被各种言论刺激,重回暴躁大佬。

其次,便是通过治理框架有效地管理社区,多权分立、避免独裁,并且成员间相互约束,基于“共识”发展。具体来说便是有一份好的“手册(原则、准则等)”,以及一个让人信服、能公证管理的团队。

比如在 Apache 软件基金会中,便采用精英治理的模式。Apache Way 中对于决策、监督、执行等各方面都有明确规定:包括结构扁平,无论职位如何,各个角色间是平等的,投票权重相同,不允许有仁慈的独裁者出现;单个项目由自选的活跃志愿者团队监督,倾向在达成共识的前提下决定项目发展,不能完全建立共识则用投票等方式做出决策;整个基金会的治理模型基于信任和委托监督……

在开放原子开源软件基金会中,项目的毕业标准考核中也有类似的关于社区需符合“贤能治理”的规定:

OA-CO-40

【英】The community strives to be meritocratic and over time aims to give more rights and responsibilities to contributors who add value to the project.

【中】社区要符合贤能治理的精神,随着时间的推移,为项目增值的贡献者会被赋予更多的权利和责任

TOC 成员徐亮曾介绍,贤能治理这四个字是经过很多轮讨论确定下来的。在英文语境中,这个词是 meritocracy,并不完全是一个褒义词,“我们并不觉得用 meritocracy 形容社区是完全正确的事情,但是我们又觉得在开源社区中,所谓的能者上氛围是比较积极向上的,是社区能够健康运转的主要因素。”

在许多项目中,一方面大家是贡献者,另一方面,每个人提交的功能越重要,或者对项目本身做出了更有意义的贡献,就更有可能被现任维护人员选中去承担更重要的角色。通过这种方式达成的精英治理或是贤能治理,往往是希望能够让项目可以自己成长,更好地走下去。

Ubuntu 的创始人 Mark Shuttleworth 在遭到信任危机之后,便着手参与将 Ubuntu 转向经营治理的模式。目前,Ubuntu 社区也重新组建了由 3 名核心成员组成的管理团队,分别是 Ubuntu 社区代表 Monica Ayhens-Madon,Ubuntu 开发者关系负责人 Rhys Davies,以及临时社区经理 Ken VanDine。Ken 还将负责继续为 Ubuntu 社区寻找一位合适的社区总监。

最后,借用一个当下共识:开源比以往任何时候都更加重要。也因此,维护一个好的社区氛围,正尤为重要。

来源:OSC开源社区

收起阅读 »

面试题 | 等待多个并发结果有哪几种方法?

引子 App 开发中,等待多个异步结果的场景很多见, 比如并发地在后台执行若干个运算,待所有运算执行完毕后归总结果。 比如并发地请求若干个接口,待所有结果返回后刷新界面。 比如统计相册页并发加载 20 张图片的耗时。 其实把若干异步任务串行化是最简单的解决办法...
继续阅读 »

引子


App 开发中,等待多个异步结果的场景很多见,


比如并发地在后台执行若干个运算,待所有运算执行完毕后归总结果。


比如并发地请求若干个接口,待所有结果返回后刷新界面。


比如统计相册页并发加载 20 张图片的耗时。


其实把若干异步任务串行化是最简单的解决办法,即前一个异步任务执行完毕后再执行下一个。但这样就无法利用多核性能,执行时间被拉长,此时的执行总时长 = 所有任务执行时长的和。


若允许任务并发,则执行总时长 = 执行时间最长任务的耗时。时间性能得以优化,但随之而来的一个复杂度是:“如何等待多个异步结果”。


本文会介绍几种解决方案,并将它们运用到不同的业务场景,比对一下哪个方案适用于哪个场景。


等待并发网络请求


布尔值


假设有如下两个网络请求:


// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) { ... }
})
}
// 拉取广告
fun fetchAd() {
newsApi.fetchAd().enqueue(object : Callback<List<Ad>> {
override fun onFailure(call: Call<List<Ad>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<Ad>>, response: Response<List<Ad>>) { ... }
})
}

广告需要按一定规则插入到新闻列表中。


最简单的做法是,先请求新闻,待其返回后再请求广告。显然这会增加用户等待时间。而且会写出这样的代码:


// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<News> {
override fun onFailure(call: Call<News>, t: Throwable) { ... }
override fun onResponse(call: Call<News>, response: Response<News>) {
// 拉取广告
newsApi.fetchAd().enqueue(object : Callback<Ad> {
override fun onFailure(call: Call<Ad>, t: Throwable) { ... }
override fun onResponse(call: Call<Ad>, response: Response<Ad>) { ... }
})
}
})
}

嵌套回调,若再加一个接口,回调层次就会再加一层,不能忍。
用户和程序员的体验都不好,得想办法解决。


第一个想到的方案是布尔值:


var isNewsDone = false
var isAdDone = false
var news = emptyList()
var ads = emptyList()
// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
isNewsDone = true
tryRefresh(news, ad)
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
isNewsDone = true
news = response.body().result
tryRefresh(news, ad)
}
})
}
// 拉取广告
fun fetchAd() {
newsApi.fetchAd().enqueue(object : Callback<List<Ad>> {
override fun onFailure(call: Call<List<Ad>>, t: Throwable) {
isAdDone = true
tryRefresh(news, ad)
}
override fun onResponse(call: Call<List<Ad>>, response: Response<List<Ad>>) {
isAdDone = true
ads = response.body().result
tryRefresh(news, ad)
}
})
}
// 尝试刷新界面(只有当两个请求都返回时才刷新)
fun tryRefresh(news: List<News>, ads: List<Ad>) {
if(isNewsDone && isAdDone){ //刷新界面 }
}

设置两个布尔值分别对应两个请求是否返回,并且在每个请求返回时检测两个布尔值,若都为 true 则进行刷新界面。


网络库通常会将请求成功的回调抛到主线程执行,所以这里没有线程安全问题。但如果不是网络请求,而是后台任务,此时需要将布尔值声明为volatile保证其可见性,关于 volatile 更详细的解释可以点击面试题 | 徒手写一个非阻塞线程安全队列 ConcurrentLinkedQueue?


这个方案能解决问题,但只适用于并发请求数量很少的请求,因为每个请求都要声明一个布尔值。而且每增加一个请求都要修改其余请求的代码,可维护性差。


CountdownLatch


更好的方案是CountDownLatch,它是java.util.concurrent包下的一个类,用来等待多个异步结果,用法如下:


val countdownLatch = CountDownLatch(2)//初始化,等待2个异步结果
var news = emptyList()
var ads = emptyList()
// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
countdownLatch.countDown()
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
news = response.body().result
countdownLatch.countDown()
}
})
}
// 拉取广告
fun fetchAd() {
newsApi.fetchAd().enqueue(object : Callback<List<Ad>> {
override fun onFailure(call: Call<List<Ad>>, t: Throwable) {
countdownLatch.countDown()
}
override fun onResponse(call: Call<List<Ad>>, response: Response<List<Ad>>) {
ads = response.body().result
countdownLatch.countDown()
}
})
}
// countdownLatch 在新线程中等待
thread {
countdownLatch.await() // 阻塞线程等待两个请求返回
liveData.postValue() // 抛数据到主线程刷刷新界面
}.start()

CountDownLatch 在构造时需传入一个数量,它的语义可以理解为一个计数器。countDown() 将计数器减一,而 await() 会阻塞当前线程直到计数器为 0 才被唤醒。


该计数器是一个 int 值,可能被多线程访问,为了保证线程安全,它被声明为 volatile,并且 countDown() 通过 CAS + 自旋的方式将其减一。


关于 CAS 的介绍可以点击面试题 | 徒手写一个非阻塞线程安全队列 ConcurrentLinkedQueue?


若新增一个接口,只需要将计数器的值加一,并在新接口返回时调用 countDown() 即可,可维护性陡增。


协程


Kotlin 是降低复杂度的大师,它对于这个问题的解决方案可以让代码看上去更简单。


在 Kotlin 的世界里异步操作应该被定义为suspend方法,retrofit 就支持这样的操作,比如:


interface NewsApi {
@GET("/xxx")
suspend fun fetchNews(): List<News>
@GET("/xxx")
suspend fun fetchAd(): List<Ad>
}

然后在协程中使用async启动异步任务:


scope.launch {
// 并发地请求网络
val newsDefered = async { fetchNews() }
val adDefered = async { fetchAd() }
// 等待两个网络请求返回
val news = newsDefered.await()
val ads = adDefered.await()
// 刷新界面
refreshUi(news, ads)
}

不管是写起来还是读起来,体验都非常好。因为协程把回调干掉了,逻辑不会跳来跳去。


其中的async()是 CoroutineScope 的扩展方法:


// 启动协程,并返回协程执行结果
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
...
}

async() 和 launch() 唯一的不同是它的返回值是Defered,用于描述协程体执行的结果:


public interface Deferred<out T> : Job {
// 挂起方法: 等待值的计算,但不会阻塞当前线程,计算完成后恢复当前协程执行
public suspend fun await(): T
}

调用async()启动子协程不会挂起外层协程,而是立即返回一个Deferred对象,直到调用Deferred.await()协程的执行才会被挂起。当协程在多个Deferred对象上被挂起时,只有当它们都恢复后,协程才继续执行。这样就实现了“等待多个并行的异步结果”。


但这样写会问题:当广告拉取抛出异常时,新闻拉取也会被取消。


这是协程的一个默认设定,叫结构化并发,即并发是有结构性的。


Java 中线程的并发是没有结构的,所以做如下事情很困难:



  1. 结束一个线程时,如何一并结束它所有的子线程?

  2. 当某个子线程抛出异常时,如何结束和它同一层级的兄弟线程?

  3. 父线程如何等待所有子线程结束之后才结束?


之所以会很困难,是因为 Java 中的线程是没有级联关系的。而 Kotlin 通过协程域 CoroutineScope 以及协程上下文 CoroutineContext 实现级联关系。


在协程中启动的子协程会继承父协程的协程上下文,除了其中的 Job,一个新的 Job 会被创建并归属于父协程的子 Job。通过这套机制,协程和子协程之间有了级联关系,就能实现结构化并发。(以后会就结构化并发写一个系列,敬请期待~)


关于 CoroutineContext 内部结构的详细剖析可以点击Kotlin 协程 | CoroutineContext 为什么要设计成 indexed set?


但有些业务场景不需要子任务之间相互关联,比如当前场景,广告加载失败不应该影响新闻的拉取,大不了不展示广告。为此 kotlin 提供了supervisorScope


scope.launch {
supervisorScope {
// 并发地请求网络
val newsDefered = async { fetchNews() }
val adDefered = async { fetchAd() }
// 等待两个网络请求返回
val news = newsDefered.await()
val ads = adDefered.await()
// 刷新界面
refreshUi(news, ads)
}
}

supervisorScope 新建一个协程域继承父亲的协程上下文,但会将其中的 Job 重写为SupervisorJob,它的特点就是孩子的失败不会影响父亲,也不会影响兄弟。


现在广告和新闻加载互不影响,各自抛异常都不会影响对方。但就目前的业务场景来说,理想情况是这样的:“广告加载失败不应该影响新闻的加载。但新闻加载失败应该取消广告的加载(因为此时广告也没有展示的机会)”


稍改动下代码:


scope.launch {
supervisorScope {
// 并发地请求网络
val adDefered = async { fetchAd() }
val newsDefered = async { fetchNews() }
// 当新闻请求抛异常时,取消广告请求
newsDefered.invokeOnCompletion { throwable ->
throwable?.let { adDefered.cancel() }
}
// 等待新闻
val news = try {
newsDefered.await()
} catch (e: Exception) {
emptyList()
}
// 等待广告
val ads = try {
adDefered.await()
} catch (e: Exception) {
emptyList()
}
// 刷新界面
refreshUi(news, ads)
}
}

invokeOnCompletion()相当于注册了一个回调,在异步任务结束时调用,不管是正常结束还是因异常而结束。在该回调中判断,若新闻因异常而结束则取消广告任务。


因为新闻和广告任务都可能抛出异常,且 async 启动的异步任务是在调用 await() 时才会抛出异常,所以它应该包裹在 try-catch 中。Kotlin 中的 try-catch 是一个表达式,即是有返回值的。这个特性让正常和异常情况的值聚合在一个表达式中。


若不使用 try-catch,程序也不会奔溃,因为 supervisorScope 中异常是不会向上传播的,即子协程的异常不会影响兄弟和父亲。但这样就少了异常情况的处理。


若现有代码都是 Callback 形式的,还能不能享受协程的简洁?


能!Kotlin 提供了suspendCoroutine(),专门用于将回调风格的代码转换成 suspend 方法,以拉取新闻为例:


// Callback 形式
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) { ... }
})
}

// suspend 形式
suspend fun fetchNews() = suspendCoroutine<List<News>> { continuation ->
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
continuation.resumeWithException(t)
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
continuation.resume(response.body().result)
}
})
}

其中的Continuation剩余的计算,从形式上看,它就是一个回调:


public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>) // 开始剩余的计算
}

每个 suspend 方法被编译成 java 之后,都会在原有方法参数表最后添加一个 Continuation 参数,用于表达这个挂起点之后“剩余的计算”,举个例子:


scope.launch {
fun1() // 普通方法
suspendFun1() // 挂起方法
// --------------------------
fun2() // 普通方法
suspendFun2() // 挂起方法
// --------------------------
}

整个协程体中有四个方法,其中两个是挂起方法,每个挂起方法都是一道水平的分割线,分割线下方的代码就是当前执行点相对于整个协程体剩余的计算,这“剩余的计算”会被包装成 Continuation 并作为参数传入挂起方法。所以上述代码翻译成 java 就类似于:


scope.launch {
fun1()
suspendFun1(new Continuation() {
@override
public void resumeWith(Result<T> result) {
fun2()
suspendFun2(new Continuation() {
@override
public void resumeWith(Result<T> result) {

}
})
}
})
}

所以挂起方法无异于 java 中带回调的方法,它自然不会阻塞当前线程,它只是把协程体中剩下的代码当成回调,该回调会在将来某个时间点被执行。通过这种方式,挂起方法主动让出了 cpu 执行权。


题外话


从业务上讲,将 Callback 方法改造成挂起式可以降低业务复杂度。举个例子:用户可以通过若干动作触发拉取新闻,比如首次进入新闻页、下拉刷新新闻页、上拉加载更多新闻、切换分区。新闻页有一个埋点,当首次展示某分区时,上报此时的新闻。


若没有 suspend 方法,代码应该这样写:


// NewsViewModel.kt
fun fetchNews(isFirstLoad: Boolean, isChangeType: Boolean) {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
// 将新闻抛给界面刷新
newsLiveData.value = response.body.result
// 只有当首次加载或切换分区时时才埋点
if(isFirstLoad || isChangeType) {
reportNews(response.body.result)
}
}
})
}
// NewsActivity.kt
// 分区切换监听
tab.setOnTabChangeListener { index ->
newsViewModel.fetchNews(false, true)
}
// 首次加载新闻
fun init() {
newsViewModel.fetchNews(true, false)
}
// 下拉刷新
refreshLayout.setOnRefreshListener {
newsViewModel.fetchNews(false, false)
}
// 上拉加载更多
refreshLayout.setOnLoadMoreListener {
newsViewModel.fetchNews(false, false)
}

因为埋点需要带上新闻列表,所以必须在请求返回之后上报。不同业务场景的拉取接口是同一个,所以只能在统一的 onResponse() 中分类讨论,分类讨论依赖于标记位,不得不为 fetchNews() 添加两个参数。


如果将拉取新闻的接口改成 suspend 方式就能化解这类复杂度:


// NewsViewModel.kt
suspend fun fetchNews() = suspendCoroutine<List<News>> { continuation ->
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
continuation.resumeWithException(t)
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
val news = response.body.result
newsLiveData.value = news
continuation.resume(news)
}
})
}

// NewsActivity.kt
fun initNews() {
scope.launch {
val news = viewModel.fetchNews()
reportNews(news)
}
}

fun changeNewsType() {
scope.launch {
val news = viewModel.fetchNews()
reportNews(news)
}
}

fun loadMoreNews() {
scope.launch { viewModel.fetchNews() }
}

fun refreshNews() {
scope.launch { viewModel.fetchNews() }
}

newsViewModel.newsLiveData.observe {news ->
showNews(news)
}

所有界面的刷新还是走 LiveData,但拉取新闻的方法被改造成挂起之后,也会将新闻列表用类似同步的方式返回,所以可以在相关业务点进行单独埋点。


统计相册加载图片耗时


再通过一个更高并发数的场景比对下各个方案代码上的差异,场景如下:


1657970793022(1).gif


测试并发加载 20 张网络图片的总耗时。该场景下已经无法使用布尔值,因为并发数太多。


CountdownLatch


var start = SystemClock.elapsedRealtime()
var imageUrls = listOf(...)
val countdownLatch = CountDownLatch(imageUrls.size)
// 另起线程等待 CountDownLatch 并输出耗时
scope.launch(Dispatchers.IO) {
countdownLatch.await()
Log.d( "test", "time-consume=${SystemClock.elapsedRealtime() - start}" )
}

// 遍历 20 张图片 url
imageUrls.forEach { img ->
ImageView {// 动态构建 ImageView
layout_width = 100
layout_height = 100
Glide.with(this@GlideActivity)
.load(img)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
countdownLatch.countDown() // 加载完一张
return false
}

override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
countdownLatch.countDown() // 加载完一张
return false
}
})
.into(this)
}
}

协程


var imageUrls = listOf(...)
scope.launch {
val start = SystemClock.elapsedRealtime()
// 将每个 url 都变换为一个 Defered
val defers = imageUrls.map { img ->
val imageView = ImageView {
layout_width = 100
layout_height = 100
}
async { imageView.loadImage(img) }
}
defers.awaitAll()//等待所有的异步任务结束
Log.d( "test", "time-consume=${SystemClock.elapsedRealtime() - start}" )
}
// 将 Callback 方式的加载转换为挂起方式
private suspend fun ImageView.loadImage(img: String) = suspendCoroutine<String> { continuation ->
Glide.with(this@GlideActivity)
.load(img)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
continuation.resume("")
return false
}

override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
continuation.resume("")
return false
}
})
.into(this)
}

你更喜欢哪种方式?


作者:唐子玄
链接:https://juejin.cn/post/7121517604644061192
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 实现App应用退到后台显示通知

需求背景 刚开始接到这个需求时,第一时间想到的是做成跟银行类app一样用户退到主页之后,需要在通知栏显示“XXX在后台运行”,并且该通知不能被清除,只有用户重新进入app再消失。然后就想到了一个方案前台服务(foregroundService) 来实现,于是撸...
继续阅读 »
需求背景

刚开始接到这个需求时,第一时间想到的是做成跟银行类app一样用户退到主页之后,需要在通知栏显示“XXX在后台运行”,并且该通知不能被清除,只有用户重新进入app再消失。然后就想到了一个方案前台服务(foregroundService) 来实现,于是撸起袖子就是干。



  • 1、创建一个ForegroundService继承Service

  • 2、重写onCreate等一系列方法

  • 3、创建通知,根据不同版本来开启服务



根据不同版本开启服务



  • 4、监听Application的生命周期,在onActivityStopped中显示前台服务,在onActivityResumed中取消前台服务


显示前台服务


关闭前台服务


搞定,运行代码看看效果。。。


哦豁


完全不对,遇到的问题:



  • 1、并不是所有onActivityStopped执行都是应用被切换至后台---此处百度“如何监听应用被切换至后台”

  • 2、onActivityResumed的时候stopService如果操作快一下到后台一下到前台会收到一大堆的崩溃信息



崩溃信息


遇到问题那咱就解决问题呗,开干~~



  • 1、这个问题倒是很好解决,百度上一大把,添加一个refCount变量,在onActivityStarted方法中++,在onActivityStopped方法中--,然后在onActivityStopped中判断当refCount等于0时表示应用退到后台


变量++


变量--



  • 2、这个问题崩溃的信息意思就是调用了startForegroundService之后没有调用 Service.startForeground()方法,造成这个问题的原因就是短时间内重复进入退出应用,前台服务来不及start就已经被stop
    那怎么办呢?
    第一时间想到的是延迟几秒再stopService,写完运行结果还是一大堆崩溃0.0


于是:于是:发自内心的问自己,为什么要用前台服务?为什么要用前台服务?有没有其他方案呢?


答案肯定是有的,为什么一定要用前台服务呢?直接用通知不行么,好,就用通知


于是,就用一个通知管理类ForegroundPushManager来处理通知的显示和关闭


关闭通知


显示通知


这样就完成了应用退到后台显示通知的功能了。


最后效果


最后遇到的第二个问题如果有好的方案解决的话请大家踊跃指点,谢谢!!


Demo地址:github.com/ling9400/Fo…


作者:ling9400
链接:https://juejin.cn/post/7054790805889744932
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

七夕节马上要到了,前端工程师,后端工程师,算法工程师都怎么哄女朋友开心?

这篇文章的前提是,你得有个女朋友,没有就先收藏着吧!七夕节的来源是梁山伯与祝英台的美丽传说,化成了一对蝴蝶~ 美丽的神话!虽然现在一般是过214的情人节了,但是不得不说,古老的传统的文化遗产,还是要继承啊~在互联网公司中,主要的程序员品种包括:前端工程师,后端...
继续阅读 »

这篇文章的前提是,你得有个女朋友,没有就先收藏着吧!

七夕节的来源是梁山伯与祝英台的美丽传说,化成了一对蝴蝶~ 美丽的神话!虽然现在一般是过214的情人节了,但是不得不说,古老的传统的文化遗产,还是要继承啊~

在互联网公司中,主要的程序员品种包括:前端工程师,后端工程师,算法工程师。

对于具体的职业职能划分还不是很清楚的,我们简单的介绍一下不同程序员岗位的职责:

前端程序员:绘制UI界面,与设计和产品经理进行需求的对接,绘制特定的前端界面推向用户

后端程序员:接收前端json字符串,与数据库对接,将json推向前端进行显示

算法工程师:进行特定的规则映射,优化函数的算法模型,改进提高映射准确率。

七夕节到了,怎么结合自身的的专业技能,哄女朋友开心呢?

前端工程师:我先来,画个动态的晚霞页面!

1.定义样式风格:

.star {
 width: 2px;
 height: 2px;
 background: #f7f7b6;
 position: absolute;
 left: 0;
 top: 0;
 backface-visibility: hidden;
}

2.定义动画特性

@keyframes rotate {
 0% {
   transform: perspective(400px) rotateZ(20deg) rotateX(-40deg) rotateY(0);
}

 100% {
   transform: perspective(400px) rotateZ(20deg) rotateX(-40deg) rotateY(-360deg);
}
}

3.定义星空样式数据

export default {
 data() {
   return {
     starsCount: 800, //星星数量
     distance: 900, //间距
  }
}
}

4.定义星星运行速度与规则:

starNodes.forEach((item) => {
     let speed = 0.2 + Math.random() * 1;
     let thisDistance = this.distance + Math.random() * 300;
     item.style.transformOrigin = `0 0 ${thisDistance}px`;
     item.style.transform =
         `
       translate3d(0,0,-${thisDistance}px)
       rotateY(${Math.random() * 360}deg)
       rotateX(${Math.random() * -50}deg)
       scale(${speed},${speed})`;
  });

前端预览效果图:


后端工程师看后,先点了点头,然后表示不服,画页面太肤浅了,我开发一个接口,定时在女朋友生日的时候发送祝福邮件吧!

1.导入pom.xml 文件

        <!-- mail邮件服务启动器 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-mail</artifactId>
       </dependency>

2.application-dev.properties内部增加配置链接

#QQ\u90AE\u7BB1\u90AE\u4EF6\u53D1\u9001\u670D\u52A1\u914D\u7F6E
spring.mail.host=smtp.qq.com
spring.mail.port=587

## qq邮箱
spring.mail.username=#yourname#@qq.com
## 这里填邮箱的授权码
spring.mail.password=#yourpassword#

3.配置邮件发送工具类 MailUtils.java

@Component
public class MailUtils {
   @Autowired
   private JavaMailSenderImpl mailSender;
   
   @Value("${spring.mail.username}")
   private String mailfrom;

   // 发送简单邮件
   public void sendSimpleEmail(String mailto, String title, String content) {
       // 定制邮件发送内容
       SimpleMailMessage message = new SimpleMailMessage();
       message.setFrom(mailfrom);
       message.setTo(mailto);
       message.setSubject(title);
       message.setText(content);
       // 发送邮件
       mailSender.send(message);
  }
}

4.测试使用定时注解进行注释

@Component
class DemoApplicationTests {

   @Autowired
   private MailUtils mailUtils;

   /**
    * 定时邮件发送任务,每月1日中午12点整发送邮件
    */
   @Scheduled(cron = "0 0 12 1 * ?")
   void sendmail(){
       // 定制邮件内容
       StringBuffer content = new StringBuffer();
       content.append("HelloWorld");
       //分别是接收者邮箱,标题,内容
       mailUtils.sendSimpleEmail("123456789@qq.com","自定义标题",content.toString());
  }
}

@scheduled注解 使用方法: cron:秒,分,时,天,月,年,* 号表示 所有的时间均匹配

5.工程进行打包,部署在服务器的容器中运行即可。

算法工程师,又开发接口,又画页面,我就训练一个自动写诗机器人把!

1.定义神经网络RNN结构

def neural_network(model = 'gru', rnn_size = 128, num_layers = 2):
   cell = tf.contrib.rnn.BasicRNNCell(rnn_size, state_is_tuple = True)
   cell = tf.contrib.rnn.MultiRNNCell([cell] * num_layers, state_is_tuple = True)
   initial_state = cell.zero_state(batch_size, tf.float32)
   with tf.variable_scope('rnnlm'):
       softmax_w = tf.get_variable("softmax_w", [rnn_size, len(words)])
       softmax_b = tf.get_variable("softmax_b", [len(words)])
       embedding = tf.get_variable("embedding", [len(words), rnn_size])
       inputs = tf.nn.embedding_lookup(embedding, input_data)
   outputs, last_state = tf.nn.dynamic_rnn(cell, inputs, initial_state = initial_state, scope = 'rnnlm')
   output = tf.reshape(outputs, [-1, rnn_size])
   logits = tf.matmul(output, softmax_w) + softmax_b
   probs = tf.nn.softmax(logits)
   return logits, last_state, probs, cell, initial_state

2.定义模型训练方法:

def train_neural_network():
   logits, last_state, _, _, _ = neural_network()
   targets = tf.reshape(output_targets, [-1])
   loss = tf.contrib.legacy_seq2seq.sequence_loss_by_example([logits], [targets], \
      [tf.ones_like(targets, dtype = tf.float32)], len(words))
   cost = tf.reduce_mean(loss)
   learning_rate = tf.Variable(0.0, trainable = False)
   tvars = tf.trainable_variables()
   grads, _ = tf.clip_by_global_norm(tf.gradients(cost, tvars), 5)
   #optimizer = tf.train.GradientDescentOptimizer(learning_rate)
   optimizer = tf.train.AdamOptimizer(learning_rate)
   train_op = optimizer.apply_gradients(zip(grads, tvars))

   Session_config = tf.ConfigProto(allow_soft_placement = True)
   Session_config.gpu_options.allow_growth = True

   trainds = DataSet(len(poetrys_vector))

   with tf.Session(config = Session_config) as sess:
       sess.run(tf.global_variables_initializer())

       saver = tf.train.Saver(tf.global_variables())
       last_epoch = load_model(sess, saver, 'model/')

       for epoch in range(last_epoch + 1, 100):
           sess.run(tf.assign(learning_rate, 0.002 * (0.97 ** epoch)))
           #sess.run(tf.assign(learning_rate, 0.01))

           all_loss = 0.0
           for batche in range(n_chunk):
               x,y = trainds.next_batch(batch_size)
               train_loss, _, _ = sess.run([cost, last_state, train_op], feed_dict={input_data: x, output_targets: y})

               all_loss = all_loss + train_loss

               if batche % 50 == 1:
                   print(epoch, batche, 0.002 * (0.97 ** epoch),train_loss)

           saver.save(sess, 'model/poetry.module', global_step = epoch)
           print (epoch,' Loss: ', all_loss * 1.0 / n_chunk)

3.数据集预处理

poetry_file ='data/poetry.txt'
# 诗集
poetrys = []
with open(poetry_file, "r", encoding = 'utf-8') as f:
   for line in f:
       try:
           #line = line.decode('UTF-8')
           line = line.strip(u'\n')
           title, content = line.strip(u' ').split(u':')
           content = content.replace(u' ',u'')
           if u'_' in content or u'(' in content or u'(' in content or u'《' in content or u'[' in content:
               continue
           if len(content) < 5 or len(content) > 79:
               continue
           content = u'[' + content + u']'
           poetrys.append(content)
       except Exception as e:
           pass

poetry.txt文件中存放这唐诗的数据集,用来训练模型

4.测试一下训练后的模型效果:

藏头诗创作:“七夕快乐”

模型运算的结果


哈哈哈,各种节日都是程序员的表(zhuang)演(bi) 时间,不过这些都是锦上添花,只有实实在在,真心,才会天长地久啊~

提前祝各位情侣七夕节快乐!

作者:千与编程
来源:juejin.cn/post/6995491512716918814

收起阅读 »

七夕到了,还不快给你女朋友做一个专属chrome插件

web
前言七夕节马上就要到了,作为拥有对象(没有的话,可以选择 new 一个出来)的程序员来说,肯定是需要有一点表示才行的。用钱能买到的东西不一定能表达咱们的心意,但是用心去写的代码,还能让对象每天看到那才是最正确的选择。除了手机之外,在电脑上使...
继续阅读 »

前言

七夕节马上就要到了,作为拥有对象(没有的话,可以选择 new 一个出来)的程序员来说,肯定是需要有一点表示才行的。用钱能买到的东西不一定能表达咱们的心意,但是用心去写的代码,还能让对象每天看到那才是最正确的选择。

除了手机之外,在电脑上使用浏览器搜索想要的东西是最常用的功能了,所以就需要一个打开即用的搜索框,而且还能表达心意的chrome标签页来让 TA 随时可用。

新建项目

由于我们是做chrome标签页,所以新建的项目不需要任何框架,只需要最简单的HTML、js、css即可。

在任意地方新建一个文件夹chrome

chrome目录下新建一个manifest.json文件

配置chrome插件

{
"name": "Every Day About You",
"description": "Every Day About You",
"version": "1.0",
"manifest_version": 2,
"browser_action": {
"default_icon": "ex_icon.png"
},
"permissions": [
"activeTab"
],
"content_scripts": [
{
"matches": [
""
],
"js": [
"demo.js",
"canvas.js"
],
"run_at": "document_start"
}
],
"chrome_url_overrides": {
"newtab": "demo.html"
},
"offline_enabled": true,
}
复制代码
  • name:扩展名称,加载扩展程序时显示的名称。
  • description:描述信息,用于描述当前扩展程序,限132个字符。
  • version:扩展程序版本号。
  • manifest_version:manifest文件版本号。chrome18开始必须为2。
  • browser_action:设置扩展程序的图标。
  • permissions:需要申请的权限,这里使用tab即可。
  • content_scripts:指定在页面中运行的js和css及插入时机。
  • chrome_url_overrides:新标签页打开的html文件。
  • offline_enabled:脱机运行。

还有很多配置项可以在chrome插件开发文档中查询到,这里因为不需要发布到chrome商店中,所以只需要配置一些固定的数据项。

image.png

新建HTML和JS

在配置项中的content_scriptschrome_url_overrides中分别定义了html文件和js文件,所以我们需要新建这两个文件,名称对应即可。

image.png

HTML背景

没有哪个小天使可以拒绝来自程序猿霸道的满屏小心心好吗? 接下来我来教大家做一个飘满屏的爱心。

html>
<html>
<head>
<meta charset="utf-8">
<title>Every Day About Youtitle>
<script src="http://libs.baidu.com/jquery/1.10.2/jquery.min.js">script>
<
script type="text/javascript" src="canvas.js" >script>
head>
<
body>
<
canvas id="c" style="position: absolute;z-index: -1;text-align: center;">canvas>
body>
html>
复制代码
  • 这里引入的 jquery 是 百度 的CDN(matches中配置了可以使用所有的URL,所以CDN是可以使用外部链接的。)
  • canvas.js中主要是针对爱心和背景色进行绘画。

canvas

$(document).ready(function () {
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var c = $("#c");
var w, h;
var pi = Math.PI;
var all_attribute = {
num: 100, // 个数
start_probability: 0.1, // 如果数量小于num,有这些几率添加一个新的
size_min: 1, // 初始爱心大小的最小值
size_max: 2, // 初始爱心大小的最大值
size_add_min: 0.3, // 每次变大的最小值(就是速度)
size_add_max: 0.5, // 每次变大的最大值
opacity_min: 0.3, // 初始透明度最小值
opacity_max: 0.5, // 初始透明度最大值
opacity_prev_min: .003, // 透明度递减值最小值
opacity_prev_max: .005, // 透明度递减值最大值
light_min: 0, // 颜色亮度最小值
light_max: 90, // 颜色亮度最大值
};
var style_color = find_random(0, 360);
var all_element = [];
window_resize();

function start() {
window.requestAnimationFrame(start);
style_color += 0.1;
//更改背景色hsl(颜色值,饱和度,明度)
ctx.fillStyle = 'hsl(' + style_color + ',100%,97%)';
ctx.fillRect(0, 0, w, h);
if (all_element.length < all_attribute.num && Math.random() < all_attribute.start_probability) {
all_element.push(new ready_run);
}
all_element.map(function (line) {
line.to_step();
})
}

function ready_run() {
this.to_reset();
}

function arc_heart(x, y, z, m) {
//绘制爱心图案的方法,参数x,y是爱心的初始坐标,z是爱心的大小,m是爱心上升的速度
y -= m * 10;

ctx.moveTo(x, y);
z *= 0.05;
ctx.bezierCurveTo(x, y - 3 * z, x - 5 * z, y - 15 * z, x - 25 * z, y - 15 * z);
ctx.bezierCurveTo(x - 55 * z, y - 15 * z, x - 55 * z, y + 22.5 * z, x - 55 * z, y + 22.5 * z);
ctx.bezierCurveTo(x - 55 * z, y + 40 * z, x - 35 * z, y + 62 * z, x, y + 80 * z);
ctx.bezierCurveTo(x + 35 * z, y + 62 * z, x + 55 * z, y + 40 * z, x + 55 * z, y + 22.5 * z);
ctx.bezierCurveTo(x + 55 * z, y + 22.5 * z, x + 55 * z, y - 15 * z, x + 25 * z, y - 15 * z);
ctx.bezierCurveTo(x + 10 * z, y - 15 * z, x, y - 3 * z, x, y);
}
ready_run.prototype = {
to_reset: function () {
var t = this;
t.x = find_random(0, w);
t.y = find_random(0, h);
t.size = find_random(all_attribute.size_min, all_attribute.size_max);
t.size_change = find_random(all_attribute.size_add_min, all_attribute.size_add_max);
t.opacity = find_random(all_attribute.opacity_min, all_attribute.opacity_max);
t.opacity_change = find_random(all_attribute.opacity_prev_min, all_attribute.opacity_prev_max);
t.light = find_random(all_attribute.light_min, all_attribute.light_max);
t.color = 'hsl(' + style_color + ',100%,' + t.light + '%)';
},
to_step: function () {
var t = this;
t.opacity -= t.opacity_change;
t.size += t.size_change;
if (t.opacity <= 0) {
t.to_reset();
return false;
}
ctx.fillStyle = t.color;
ctx.globalAlpha = t.opacity;
ctx.beginPath();
arc_heart(t.x, t.y, t.size, t.size);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
}
}

function window_resize() {
w = window.innerWidth;
h = window.innerHeight;
canvas.width = w;
canvas.height = h;
}
$(window).resize(function () {
window_resize();
});

//返回一个介于参数1和参数2之间的随机数
function find_random(num_one, num_two) {
return Math.random() * (num_two - num_one) + num_one;
}

start();
});
复制代码
  • 因为使用了jquery的CDN,所以我们在js中就可以直接使用 $(document).ready方法

chrome-capture-2022-6-20.gif

土豪金色的标题

为了时刻展示出对 TA 的爱,我们除了在背景中体现出来之外,还可以再文字中体现出来,所以需要取一个充满爱意的标题。

<body>
<canvas id="c" style="position: absolute;z-index: -1;text-align: center;">canvas>
<div class="middle">
<h1 class="label">Every Day About Youh1>
div>
body>
复制代码

复制代码
  • 这里引入了googleapis中的字体样式。
  • 给label一个背景,并使用了动画效果。

text_bg.png

  • 这个就是文字后面的静态图片,可以另存为然后使用的哦~

chrome-capture-2022-6-20 (1).gif

百度搜索框

对于你心爱的 TA 来说,不管干什么估计都得用百度直接搜出来,就算是看个优酷、微博都不会记住域名,都会直接去百度一下,所以我们需要在标签页中直接集成百度搜索。让 TA 可以无忧无虑的搜索想要的东西。

由于现在百度搜索框不能直接去站长工具中获取了,所以我们可以参考掘金标签页插件中的百度搜索框。

1.gif

根据掘金的标签页插件我们可以发现,输入结果之后,直接跳转到百度的网址,并在url后面携带了一个 wd 的参数,wd 也就是我们输入的内容了。

http://www.baidu.com/s?wd=这里是输入的…

<div class="search">
<input id="input" type="text">
<button>百度一下button>
div>
复制代码

复制代码
.search {
width: 750px;
height: 50px;
margin: auto;
display: flex;
justify-content: center;
align-content: center;
min-width: 750px;
position: relative;
}

input {
width: 550px;
height: 40px;
border-right: none;
border-bottom-left-radius: 10px;
border-top-left-radius: 10px;
border-color: #f5f5f5;
/* 去除搜索框激活状态的边框 */
outline: none;
}

input:hover {
/* 鼠标移入状态 */
box-shadow: 2px 2px 2px #ccc;
}

input:focus {
/* 选中状态,边框颜色变化 */
border-color: rgb(78, 110, 242);
}

.search span {
position: absolute;
font-size: 23px;
top: 10px;
right: 170px;
}

.search span:hover {
color: rgb(78, 110, 242);
}

button {
width: 100px;
height: 44px;
background-color: rgb(78, 110, 242);
border-bottom-right-radius: 10px;
border-top-right-radius: 10px;
border-color: rgb(78, 110, 242);
color: white;
font-size: 14px;
}
复制代码

chrome-capture-2022-6-20 (2).gif

关于 TA

这里可以放置你们之间的一些生日,纪念日等等,也可以放置你想放置的任何浪漫,仪式感满满~

如果你不记得两个人之间的纪念日,那就换其他的日子吧。比如你和 TA 闺蜜的纪念日也可以。

<body>
<canvas id="c" style="position: absolute;z-index: -1;text-align: center;">canvas>
<div class="middle">
<h1 class="label">Every Day About Youh1>
<div class="time">
<span>
<div id="d">
00
div>
Love day
span> <span>
<div id="h">
00
div>
First Met
span> <span>
<div id="m">
00
div>
birthday
span> <span>
<div id="s">
00
div>
age
span>
div>
div>
<script type="text/javascript" src="demo.js">script>
body>
复制代码
  • 这里我定义了四个日期,恋爱纪念日、相识纪念日、TA 的生日、TA 的年龄。
  • 在页面最后引用了一个js文件,主要是等待页面渲染完成之后调用js去计算日期的逻辑。
恋爱纪念日
var date1 = new Date('2019-10-07')
var date2 = new Date()

var s1 = date1.getTime(),
s2 = date2.getTime();

var total = (s2 - s1) / 1000;

var day = parseInt(total / (24 * 60 * 60)); //计算整数天数

const d = document.getElementById("d");

d.innerHTML = getTrueNumber(day);

复制代码
相识纪念日
var date1 = new Date('2019-09-20')
var date2 = new Date()

var s1 = date1.getTime(),
s2 = date2.getTime();

var total = (s2 - s1) / 1000;

var day = parseInt(total / (24 * 60 * 60)); //计算整数天数

h.innerHTML = getTrueNumber(day);
复制代码
公共方法(将计算出来的日子转为绝对值)
const getTrueNumber = x => (x < 0 ? Math.abs(x) : x);
复制代码

chrome-capture-2022-6-20 (3).gif

由于生日和年龄的计算代码有些多,所以放在码上掘金中展示了。

添加到chrome浏览器中

image.png

开发完成之后,所有的文件就是这样的了,里面的icon可以根据自己的喜好去设计或者网上下载。

使用chrome浏览器打开:chrome://extensions/ 即可跳转到添加扩展程序页面。

2.gif

  • 打开右上角的开发者模式
  • 点击加载已解压的扩展程序
  • 选择自己的chrome标签页项目目录即可

3.gif

总结一下

为了让心爱的 TA 开心,作为程序员的我们可谓是煞费苦心呀!!

在给对象安装插件的时候,发现了一个小问题,可能是chrome版本原因,导致jquery的cdn无法直接引用,所以可能需要手动把jquery保存到项目文件中,然后在manifest.json配置js的地方把jquery的js加上即可。

码上掘金中我已经把jquery的代码、canvas的代码、计算纪念日的代码都放进去了,可以直接复制到自己项目中哦!!!

七夕节快到了,祝愿天下有情人终成眷属!

来源:juejin.cn/post/7122332008252080142

收起阅读 »

老板连夜抠掉全公司电脑Alt键,只为限制员工摸鱼...哄堂大笑了兄弟们

有人说,优秀公司抓产品,一般公司抓业绩,奇葩公司抓“摸鱼”。 “为了防止自家员工摸鱼,亲自出手扣除键盘alt键”,自认为这样一来,员工在摸鱼时想要切换窗口界面就没有那么方便了,就能更方便自己“抓到”摸鱼的人。正所谓天下代有“才人”出,在2022年的第...
继续阅读 »



有人说,优秀公司抓产品,一般公司抓业绩,奇葩公司抓“摸鱼”。 
近日,一位私企老板就为码君生动演绎了什么叫“与人斗,其乐无穷”。 



“为了防止自家员工摸鱼,亲自出手扣除键盘alt键”,自认为这样一来,员工在摸鱼时想要切换窗口界面就没有那么方便了,就能更方便自己“抓到”摸鱼的人。
说实话,刚看到这个消息的时候,码君的脑回路一时都没有转过弯来,甚至还在帮这位老板思考这样是不是真的有什么“深远”的作用。


仔细看了几遍后,我才终于接受了世界上真的有这么“离谱”的人和事存在。 
正所谓天下代有“才人”出,在2022年的第一个月,这位老板就成功预定了“年度十大迷惑事件”之一的位置。 


而根据视频中员工的爆料,这老板在之前还有过好几次类似的操作,比如:

“在厕所内偷偷装上信号屏蔽器,防止员工‘带薪蹲坑’”;

“给员工们定中午盒饭,美名其曰修复‘中午找不到人的BUG’”



码君属实是蚌埠住了,这老板的行事风格真是槽点满满。
从动机上来说,你一位私企老板不去好好谈项目,拉合作,每天关注自己员工有没有摸鱼,这样对公司的发展真的好嘛?

从某种意义上,这种抓摸鱼的行为对你自己来说是不是也是一种摸鱼呢?这就是老板,员工的摸鱼二象性?



而从行动上来说,您跑出来扣人家键盘是什么神奇操作啊?
咱首先想想,对于某些员工自配的机械键盘来说,人家是靠轴体操作的,换个不常用键位插上基本就没差啊。
再退一步想,拔了alt键是不是也会降低员工们干正事时的效率呢?
再再退一步想,这个事件有个最关键的问题在于——快捷键这个东西,它是可以自行改的呀!



扣除个别按键就想禁用“快速关闭窗口”和“快速切换窗口”功能,未免有些太小瞧人家微软了吧。 
还是说,作为一位老板,您不会不知道这个操作吧?
而面对这样的“大无语事件”,网友们自然也是有话要说—— 



有网友将其与“周扒皮”类比,要我说,多少是有些抬举了,至少人家周扒皮学鸡叫,比“扣alt键”可专业多了。



有网友也像文章开头那样认为,屁事越多的公司,基本都混得越差。 
员工手里的活都干完了,做一些自己的闲事又有什么关系呢,咱们是打工,又不是卖身是不是?



一些设计行业的朋友更是直呼内行——你都已经把我的alt键扣了,我不摸鱼我能干嘛呢?
不如一鼓作气,把Ctrl、Shift、C、V等键位都扣了吧,这样才叫皆大欢喜。



说实话,职场上限制员工摸鱼的事情,这几年也是挺常见的,有一些可以接受,但有一些简直就是“反智”。 

就比如码君之前曾报道过的“国美监控员工手机流量使用情况”的事情。

因为员工上班摸鱼对其进行行政处罚、通报批评,就算过了两个多月,再看也依旧觉得离谱。

还有“一 iOS 开发员工因玩手机被开除”的事情,不在业务上、产品上推陈出新,每天就对着员工制度作妖。

这样的公司要是日后黄了,码君都要欢呼一句:“好似,开香槟咯!” 




说到底,打工人们奔波在这社会上,落脚在你的公司里,无非是为了一个“”字。
职场制度存在的初心也不是为了限制什么“摸鱼”行为,而是更多让员工将力气在集中在业务上。
当你公司的业务做得红火,员工收获得满满当当,哪会有那么多时间浪费在摸鱼上呢?还不上赶着做项目,挣大钱?

希望这类新闻中的老板们早日清醒,不要等到哪天真的遭到“反噬”了,才能明白这些真理啊!

你遇见过哪些防摸鱼损招?
*期待你的留言!

来源 | 抓码青年
收起阅读 »

大公司为什么禁止SpringBoot项目使用Tomcat?

前言 在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。同时,SpringBoot也支持Undertow容器,我们可以很方便的用Undertow替换Tomcat,而Undertow的...
继续阅读 »

前言


在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。同时,SpringBoot也支持Undertow容器,我们可以很方便的用Undertow替换Tomcat,而Undertow的性能和内存使用方面都优于Tomcat,那我们如何使用Undertow技术呢?本文将为大家细细讲解。


SpringBoot中的Tomcat容器


SpringBoot可以说是目前最火的Java Web框架了。它将开发者从繁重的xml解救了出来,让开发者在几分钟内就可以创建一个完整的Web服务,极大的提高了开发者的工作效率。Web容器技术是Web项目必不可少的组成部分,因为任Web项目都要借助容器技术来运行起来。在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。推荐:几乎涵盖你需要的SpringBoot所有操作


SpringBoot设置Undertow


对于Tomcat技术,Java程序员应该都非常熟悉,它是Web应用最常用的容器技术。我们最早的开发的项目基本都是部署在Tomcat下运行,那除了Tomcat容器,SpringBoot中我们还可以使用什么容器技术呢?没错,就是题目中的Undertow容器技术。SrpingBoot已经完全继承了Undertow技术,我们只需要引入Undertow的依赖即可,如下图所示。


image.png


image.png


配置好以后,我们启动应用程序,发现容器已经替换为Undertow。那我们为什么需要替换Tomcat为Undertow技术呢?


Tomcat与Undertow的优劣对比


Tomcat是Apache基金下的一个轻量级的Servlet容器,支持Servlet和JSP。Tomcat具有Web服务器特有的功能,包括 Tomcat管理和控制平台、安全局管理和Tomcat阀等。Tomcat本身包含了HTTP服务器,因此也可以视作单独的Web服务器。但是,Tomcat和ApacheHTTP服务器不是一个东西,ApacheHTTP服务器是用C语言实现的HTTP Web服务器。Tomcat是完全免费的,深受开发者的喜爱。


图片


Undertow是Red Hat公司的开源产品, 它完全采用Java语言开发,是一款灵活的高性能Web服务器,支持阻塞IO和非阻塞IO。由于Undertow采用Java语言开发,可以直接嵌入到Java项目中使用。同时, Undertow完全支持Servlet和Web Socket,在高并发情况下表现非常出色。


图片


我们在相同机器配置下压测Tomcat和Undertow,得到的测试结果如下所示:QPS测试结果对比: Tomcat


图片


Undertow


图片


内存使用对比:


Tomcat


image.png


Undertow


image.png


通过测试发现,在高并发系统中,Tomcat相对来说比较弱。在相同的机器配置下,模拟相等的请求数,Undertow在性能和内存使用方面都是最优的。并且Undertow新版本默认使用持久连接,这将会进一步提高它的并发吞吐能力。所以,如果是高并发的业务系统,Undertow是最佳选择。


最后


SpingBoot中我们既可以使用Tomcat作为Http服务,也可以用Undertow来代替。Undertow在高并发业务场景中,性能优于Tomcat。所以,如果我们的系统是高并发请求,不妨使用一下Undertow,你会发现你的系统性能会得到很大的提升。


作者:程序员追风
链接:https://juejin.cn/post/7117516341984755726
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter 小技巧之优化使用的 BuildContext

Flutter 里的 BuildContext 相信大家都不会陌生,虽然它叫 Context,但是它实际是 Element 的抽象对象,而在 Flutter 里,它主要来自于 ComponentElement 。 关于 ComponentElement...
继续阅读 »

Flutter 里的 BuildContext 相信大家都不会陌生,虽然它叫 Context,但是它实际是 Element 的抽象对象,而在 Flutter 里,它主要来自于 ComponentElement


关于 ComponentElement 可以简单介绍一下,在 Flutter 里根据 Element 可以简单地被归纳为两类:



  • RenderObjectElement :具备 RenderObject ,拥有布局和绘制能力的 Element

  • ComponentElement :没有 RenderObject ,我们常用的 StatelessWidgetStatefulWidget 里对应的 StatelessElementStatefulElement 就是它的子类。


所以一般情况下,我们在 build 方法或者 State 里获取到的 BuildContext 其实就是 ComponentElement


那使用 BuildContext 有什么需要注意的问题


首先如下代码所示,在该例子里当用户点击 FloatingActionButton 的时候,代码里做了一个 2秒的延迟,然后才调用 pop 退出当前页面。


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
Navigator.of(context).pop();
},
),
);
}
}

正常情况下是不会有什么问题,但是当用户在点击了 FloatingActionButton 之后,又马上点击了 AppBar 返回退出应用,这时候就会出现以下的错误提示。



可以看到此时 log 说,Widget 对应的 Element 已经不在了,因为在 Navigator.of(context) 被调用时,context 对应的 Element 已经随着我们的退出销毁。


一般情况下处理这个问题也很简单,那就是增加 mounted 判断,通过 mounted 判断就可以避免上述的错误


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
if (!mounted) return;
Navigator.of(context).pop();
},
),
);
}
}

上面代码里的 mounted 标识位来自于 State因为 State 是依附于 Element 创建,所以它可以感知 Element 的生命周期,例如 mounted 就是判断 _element != null;



那么到这里我们收获了一个小技巧:使用 BuildContext 时,在必须时我们需要通过 mounted 来保证它的有效性


那么单纯使用 mounted 就可以满足 context 优化的要求了吗


如下代码所示,在这个例子里:



  • 我们添加了一个列表,使用 builder 构建 Item

  • 每个列表都有一个点击事件

  • 点击列表时我们模拟网络请求,假设网络也不是很好,所以延迟个 5 秒

  • 之后我们滑动列表让点击的 Item 滑出屏幕不可见


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
);
}
}
class ListItem extends StatefulWidget {
const ListItem({Key? key}) : super(key: key);
@override
State<ListItem> createState() => _ListItemState();
}

class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
await Future.delayed(Duration(seconds: 5));
if(!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}

由于在 5 秒之内,Item 被划出了屏幕,所以对应的 Elment 其实是被释放了,从而由于 mounted 判断,SnackBar 不会被弹出。


那如果假设需要在开发时展示点击数据上报的结果,也就是 Item 被释放了还需要弹出,这时候需要如何处理


我们知道不管是 ScaffoldMessenger.of(context) 还是 Navigator.of(context) ,它本质还是通过 context 去往上查找对应的 InheritedWidget 泛型,所以其实我们可以提前获取。


所以,如下代码所示,在 Future.delayed 之前我们就通过 ScaffoldMessenger.of(context); 获取到 sm 对象,之后就算你直接退出当前的列表页面,5秒过后 SnackBar 也能正常弹出。


class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
var sm = ScaffoldMessenger.of(context);
await Future.delayed(Duration(seconds: 5));
sm.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}

为什么页面销毁了,但是 SnackBar 还能正常弹出


因为此时通过 of(context); 获取到的 ScaffoldMessenger 是存在 MaterialApp 里,所以就算页面销毁了也不影响 SnackBar 的执行。


但是如果我们修改例子,如下代码所示,在 Scaffold 上面多嵌套一个 ScaffoldMessenger ,这时候在 Item 里通过 ScaffoldMessenger.of(context) 获取到的就会是当前页面下的 ScaffoldMessenger


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
),
);
}
}

这种情况下我们只能保证Item 不可见的时候 SnackBar 还能正常弹出, 而如果这时候我们直接退出页面,还是会出现以下的错误提示,因为 ScaffoldMessenger 也被销毁了 。



所以到这里我们收获第二个小技巧:在异步操作里使用 of(context) ,可以提前获取,之后再做异步操作,这样可以尽量保证流程可以完整执行


既然我们说到通过 of(context) 去获取上层共享往下共享的 InheritedWidget ,那在哪里获取就比较好


还记得前面的 log 吗?在第一个例子出错时,log 里就提示了一个方法,也就是 State 的 didChangeDependencies 方法。



为什么是官方会建议在这个方法里去调用 of(context)


首先前面我们一直说,通过 of(context) 获取到的是 InheritedWidget ,而 当 InheritedWidget 发生改变时,就是通过触发绑定过的 Element 里 State 的didChangeDependencies 来触发更新,所以在 didChangeDependencies 里调用 of(context) 有较好的因果关系



对于这部分内容感兴趣的,可以看 Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密全面理解State与Provider



那我能在 initState 里提前调用吗


当然不行,首先如果在 initState 直接调用如 ScaffoldMessenger.of(context).showSnackBar 方法,就会看到以下的错误提示。



这是因为 Element 里会判断此时的 _StateLifecycle 状态,如果此时是 _StateLifecycle.created 或者 _StateLifecycle.defunct ,也就是在 initStatedispose ,是不允许执行 of(context) 操作。




of(context) 操作指的是 context.dependOnInheritedWidgetOfExactTyp



当然,如果你硬是想在 initState 下调用也行,增加一个 Future 执行就可以成功执行


@override
void initState() {
super.initState();
Future((){
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("initState")));
});
}


简单理解,因为 Dart 是单线程轮询执行,initState 里的 Future 相当于是下一次轮询,自然也就不在 _StateLifecycle.created 的状态下。



那我在 build 里直接调用不行吗


直接在 build 里调用肯定可以,虽然 build 会被比较频繁执行,但是 of(context) 操作其实就是在一个 map 里通过 key - value 获取泛型对象,所以对性能不会有太大的影响。


真正对性能有影响的是 of(context) 的绑定数量和获取到对象之后的自定义逻辑,例如你通过 MediaQuery.of(context).size 获取到屏幕大小之后,通过一系列复杂计算来定位你的控件。


  @override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
var padding = MediaQuery.of(context).padding;
var width = size.width / 2;
var height = size.width / size.height * (30 - padding.bottom);
return Container(
color: Colors.amber,
width: width,
height: height,
);
}

例如上面这段代码,可能会导致键盘在弹出的时候,虽然当前页面并没有完全展示,但是也会导致你的控件不断重新计算从而出现卡顿。



详细解释可以参考 Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密



所以到这里我们又收获了一个小技巧: 对于 of(context) 的相关操作逻辑,可以尽量放到 didChangeDependencies 里去处理


最后,今天主要分享了在使用 BuildContext 时的一些注意事项和技巧,如果你对于这方面还有什么疑问,欢迎留言评论。


作者:恋猫de小郭
链接:https://juejin.cn/post/7122409135055831053
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

记录一个温度曲线的View

 最近做项目需求的看到需要自定义一个温度曲线的图。由于之前的同事理解需求的时候没有很好的理解产品的需求,将温度的折线图分成了两个View,温度高的在一个View,温度低的在一个View。这样的做法其实是没有很好的理解产品的需求的。为什么这么说,因为一...
继续阅读 »

image-20220713155216246.png 最近做项目需求的看到需要自定义一个温度曲线的图。由于之前的同事理解需求的时候没有很好的理解产品的需求,将温度的折线图分成了两个View,温度高的在一个View,温度低的在一个View。这样的做法其实是没有很好的理解产品的需求的。为什么这么说,因为一旦拆成两个View,那么哪些相交的点绘制就会有缺陷了。什么意思,看图。

image-20220713155901206.png

如果按照两个View去做,就会有这种局限性。相交的点就会被切。所以这里就重新修改了这个自定义View。

有了上面的需求,那么就开始我们的设计了。首先为了我们自定义View的能比较好的通用性,我们需要把一些可能会变的东西提取出来。这里只是提取一些很常用的属性,其余需要自定义的,可自己加上。直接看代码

<declare-styleable name="NewWeatherChartView">
   <!--开始的x坐标-->
   <attr name="new_start_point_x" format="dimension"/>
   <!--两点之间x坐标的间隔-->
   <attr name="new_point_x_margin" format="dimension"/>
   <!--显示温度的字体大小-->
   <attr name="temperature_text_size" format="dimension"/>
   <!--圆点的半径-->
   <attr name="point_radius" format="dimension"/>

   <!--选中天气项,温度字体的颜色-->
   <attr name="select_temperature_text_color" format="reference|color"/>
   <!--未选中天气项,温度字体的颜色-->
   <attr name="unselect_temperature_text_color" format="reference|color"/>
   <!--选中天气项,圆点的颜色-->
   <attr name="select_point_color" format="reference|color"/>
   <!--未选中天气项,圆点的颜色-->
   <attr name="unselect_point_color" format="reference|color"/>
<!--连接线的颜色-->
   <attr name="line_color" format="reference|color"/>
   <!--连接线的类型,可以是实线,也可以是虚线,默认是虚线。0虚线,1实线-->
   <attr name="line_type" format="integer"/>

</declare-styleable>
public class NewWeatherChartView extends View {
   private final static String TAG = "NewWeatherChartView";
   private List<WeatherInfo> items;//温度的数据源

   //都是可以在XML里面配置的属性,目前项目里面都是用的默认配置。
   private int mLineColor;
   private int mSelectTemperatureColor;
   private int mUnSelectTemperatureColor;
   private int mSelectPointColor;
   private int mUnselectPointColor;
   private int mLineType;
   private int mTemperatureTextSize;
   private int mPointStartX = 0;
   private int mPointXMargin = 0;
   private int mPointRadius;


   
   private Point[] mHighPoints; //高温的点的坐标
   private Point[] mLowPoints; //低温的点的坐标

   //这里是为了方便写代码,多创建了几个画笔,也可以用一个画笔,然后配置不同的属性
   private Paint mLinePaint; //用于画线画笔
   private Paint mTextPaint; // 用于画小圆点旁边的温度文字的画笔
   private Paint mCirclePaint;//用来画小圆点的画笔
 

   private Float mMaxTemperature = Float.MIN_VALUE;//最高温度
   private Float mMinTemperature = Float.MAX_VALUE;//最低温度
   private Path mPath;//连接线的路径
   
   private DecimalFormat mDecimalFormat;


   private int mTodayIndex = -1;//用于判断哪一个被选中

   private Context mContext;
...
}

以上就是一些初始化的东西了,那么现在就来思考一下,怎么去画这些东西,上面的初始化也说明了,我们主要是画线,画文字,然后画圆点。那么应该从哪开始呢?首先是从点坐标开始,因为无论是线,还是文字,他们的位置和点都有关系。那么找到点的坐标就是首要的工作。怎么找点的坐标,以及最开始的X坐标是多少。第一个点的X坐标是根据我们的配置来的,那么第二个点的x坐标呢?,第二个点的x坐标就是第一个点的x坐标加上他们之间的在X方向上距离,而在x方向上的距离也是根据属性配置的。所以我们可以很容易得到所有点的x坐标。那么圆点的y坐标呢?首先我们看一张图。

image-20220713172903532.png

我们的点,应该是均匀分布在剩余高度里面的。

剩余高度 = 控件高度-2*文字的高度。

点的y坐标为

*剩余高度-((当前温度-最低温度)/(最高温度-最低温度)剩余高度)+文字高度

看起来有点复杂,但是有公式的话,代码会比较简单。接下来就需要看初始化的代码了和计算点坐标的代码了

代码如下:

//首先从两个参数的构造函数里面获取各种配置的值
public NewWeatherChartView(Context context, AttributeSet attrs) {
   super(context, attrs);
   TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.NewWeatherChartView);
   mPointStartX = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_new_start_point_x, 0);
   mPointXMargin = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_new_point_x_margin, 0);
   mTemperatureTextSize = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_temperature_text_size, 20);
   mPointRadius = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_point_radius, 8);

   mSelectPointColor = typedArray.getColor(R.styleable.NewWeatherChartView_select_point_color, context.getResources().getColor(R.color.weather_select_point_color));
   mUnselectPointColor = typedArray.getColor(R.styleable.NewWeatherChartView_unselect_point_color, context.getResources().getColor(R.color.weather_unselect_point_color));
   mLineColor = typedArray.getColor(R.styleable.NewWeatherChartView_line_color, context.getResources().getColor(R.color.weather_line_color));
   mSelectTemperatureColor = typedArray.getColor(R.styleable.NewWeatherChartView_select_temperature_text_color, context.getResources().getColor(R.color.weather_select_temperature_color));
   mUnSelectTemperatureColor = typedArray.getColor(R.styleable.NewWeatherChartView_unselect_temperature_text_color, context.getResources().getColor(R.color.weather_unselect_temperature_color));

   mLineType = typedArray.getInt(R.styleable.NewWeatherChartView_line_type, 0);

   this.mContext = context;
   typedArray.recycle();
}

private void initData() {
   //初始化线的画笔
   mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
   mLinePaint.setStyle(Paint.Style.STROKE);
   mLinePaint.setStrokeWidth(2);
   mLinePaint.setDither(true);
   //配置虚线
   if (mLineType == 0) {
       DashPathEffect pathEffect = new DashPathEffect(new float[]{10, 5}, 1);
       mLinePaint.setPathEffect(pathEffect);
  }
   mPath = new Path();

   //初始化文字的画笔
   mTextPaint = new Paint();
   mTextPaint.setAntiAlias(true);
   mTextPaint.setTextSize(sp2px(mTemperatureTextSize));
   mTextPaint.setTextAlign(Paint.Align.CENTER);

   // 初始化圆点的画笔
   mCirclePaint = new Paint();
   mCirclePaint.setStyle(Paint.Style.FILL);

   mDecimalFormat = new DecimalFormat("0");

   for (int i = 0; i < items.size(); i++) {
       float highY = items.get(i).getHigh();
       float lowY = items.get(i).getLow();
       if (highY > mMaxTemperature) {
           mMaxTemperature = highY;
      }
       if (lowY < mMinTemperature) {
           mMinTemperature = lowY;
      }
       if (DateUtil.fromTodayDate(items.get(i).getDate()) == 0) {
           mTodayIndex = i;
      }
  }
   float span = mMaxTemperature - mMinTemperature;
   //这种情况是为了防止所有温度都一样的情况
   if (span == 0) {
       span = 6.0f;
  }
   mMaxTemperature = mMaxTemperature + span / 6.0f;
   mMinTemperature = mMinTemperature - span / 6.0f;

   mHighPoints = new Point[items.size()];
   mLowPoints = new Point[items.size()];
}

public int sp2px(float spValue) {
   return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, spValue, Resources.getSystem().getDisplayMetrics());
}

public int dip2px(float dpValue) {
   return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, Resources.getSystem().getDisplayMetrics());

}

这些准备工作昨晚之后,我们就可以去onDraw里面画图了。

protected void onDraw(Canvas canvas) {
   Logging.d(TAG, "onDraw: ");
   if (items == null) {
       return;
  }
   int pointX = mPointStartX; // 开始的X坐标
   int textHeight = sp2px(mTemperatureTextSize);//文字的高度
   int remainingHeight = getHeight() - textHeight * 2;//除去文字后,剩余的高度

   // 计算每一个点的X和Y坐标
   for (int i = 0; i < items.size(); i++) {
       int x = pointX + mPointXMargin * i;
       float highTemp = items.get(i).getHigh();
       float lowTemp = items.get(i).getLow();
       int highY = remainingHeight - (int) (remainingHeight * ((highTemp - mMinTemperature) / (mMaxTemperature - mMinTemperature))) + textHeight;
       int lowY = remainingHeight - (int) (remainingHeight * ((lowTemp - mMinTemperature) / (mMaxTemperature - mMinTemperature))) + textHeight;
       mHighPoints[i] = new Point(x, highY);
       mLowPoints[i] = new Point(x, lowY);
  }

   // 画线
   drawLine(mHighPoints, canvas);
   drawLine(mLowPoints, canvas);
   for (int i = 0; i < mHighPoints.length; i++) {
       // 画文本度数 例如:3°
       String yHighText = mDecimalFormat.format(items.get(i).getHigh());
       String yLowText = mDecimalFormat.format(items.get(i).getLow());
       int highDrawY = mHighPoints[i].y - dip2px(mPointRadius + 8);
       int lowDrawY = mLowPoints[i].y + dip2px(mPointRadius + 8 + sp2px(mTemperatureTextSize));

       if (i == mTodayIndex) {
           mTextPaint.setColor(mSelectTemperatureColor);
           mCirclePaint.setColor(mSelectPointColor);
      } else {
           mTextPaint.setColor(mUnSelectTemperatureColor);
           mCirclePaint.setColor(mUnselectPointColor);
      }
       canvas.drawText(yHighText + "°", mHighPoints[i].x, highDrawY, mTextPaint);
       canvas.drawText(yLowText + "°", mLowPoints[i].x, lowDrawY, mTextPaint);
       canvas.drawCircle(mHighPoints[i].x, mHighPoints[i].y, mPointRadius, mCirclePaint);
       canvas.drawCircle(mLowPoints[i].x, mLowPoints[i].y, mPointRadius, mCirclePaint);

  }
}


private void drawLine(Point[] ps, Canvas canvas) {
   Point startp;
   Point endp;
   mPath.reset();
   mLinePaint.setAntiAlias(true);
   for (int i = 0; i < ps.length - 1; i++) {
       startp = ps[i];
       endp = ps[i + 1];
       mLinePaint.setColor(mLineColor);
       canvas.drawLine(startp.x, startp.y, endp.x, endp.y, mLinePaint);
  }
}

以上就是所有关键代码了,当然,还有一个赋值的代码

public void setData(List<WeatherInfo> list) {
   this.items = list;
   initData();
}

来看一下最后的效果图吧。

image-20220713194524550.png 以上就是一个简单的温度图了,但是这个图有很多地方可以优化,也有很多地方可以提取出来当作属性。比如我举一个优化的点,文字的测量,上面的代码对文字的测量其实是非常粗糙的。仔细观察会发现上面一条线,文字距离点的距离和下面一条线文字距离点的距离是不一样的。这就是上面没有进行文字测量的结果,我这里进行了一轮文字测量的优化,如下图: image-20220713194423946.png 这里是不是好很多了呢?大家还可以进行很多地方的优化。以上就是这篇文章的全部内容了。


作者:爱海贼的小码农
链接:https://juejin.cn/post/7119826029463470088
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android全局的通知的弹窗

需求分析 如何创建一个全局通知的弹窗?如下图所示。 从手机顶部划入,短暂停留后,再从顶部划出。 首先需要明确的是: 1、这个弹窗的弹出逻辑不一定是当前界面编写的,比如用户上传文件,用户可能继续浏览其他页面的内容,但是监听文件是否上传完成还是在原来的Activ...
继续阅读 »

需求分析


如何创建一个全局通知的弹窗?如下图所示。


image.png


从手机顶部划入,短暂停留后,再从顶部划出。


首先需要明确的是:

1、这个弹窗的弹出逻辑不一定是当前界面编写的,比如用户上传文件,用户可能继续浏览其他页面的内容,但是监听文件是否上传完成还是在原来的Activity,但是Dialog的弹出是需要当前页面的上下文Context的。


2、Dialog弹窗必须支持手势,用户在Dialog上向上滑时,Dialog需要退出,点击时可能需要处理点击事件。


一、Dialog的编写


/**
* 通知的自定义Dialog
*/
class NotificationDialog(context: Context, var title: String, var content: String) :
Dialog(context, R.style.dialog_notifacation_top) {

private var mListener: OnNotificationClick? = null
private var mStartY: Float = 0F
private var mView: View? = null
private var mHeight: Int? = 0

init {
mView = LayoutInflater.from(context).inflate(R.layout.common_layout_notifacation, null)
}


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(mView!!)
window?.setGravity(Gravity.TOP)
val layoutParams = window?.attributes
layoutParams?.width = ViewGroup.LayoutParams.MATCH_PARENT
layoutParams?.height = ViewGroup.LayoutParams.WRAP_CONTENT
layoutParams?.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
window?.attributes = layoutParams
window?.setWindowAnimations(R.style.dialog_animation)
//按空白处不能取消
setCanceledOnTouchOutside(false)
//初始化界面数据
initData()
}

private fun initData() {
val tvTitle = findViewById<TextView>(R.id.tv_title)
val tvContent = findViewById<TextView>(R.id.tv_content)
if (title.isNotEmpty()) {
tvTitle.text = title
}

if (content.isNotEmpty()) {
tvContent.text = content
}
}


override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (isOutOfBounds(event)) {
mStartY = event.y
}
}

MotionEvent.ACTION_UP -> {
if (mStartY > 0 && isOutOfBounds(event)) {
val moveY = event.y
if (abs(mStartY - moveY) >= 20) { //滑动超过20认定为滑动事件
//Dialog消失
} else { //认定为点击事件
//Dialog的点击事件
mListener?.onClick()
}
dismiss()
}
}
}
return false
}

/**
* 点击是否在范围外
*/
private fun isOutOfBounds(event: MotionEvent): Boolean {
val yValue = event.y
if (yValue > 0 && yValue <= (mHeight ?: (0 + 40))) {
return true
}
return false
}


private fun setDialogSize() {
mView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
mHeight = v?.height
}
}

/**
* 显示Dialog但是不会自动退出
*/
fun showDialog() {
if (!isShowing) {
show()
setDialogSize()
}
}

/**
* 显示Dialog,3000毫秒后自动退出
*/
fun showDialogAutoDismiss() {
if (!isShowing) {
show()
setDialogSize()
//延迟3000毫秒后自动消失
Handler(Looper.getMainLooper()).postDelayed({
if (isShowing) {
dismiss()
}
}, 3000L)
}
}

//处理通知的点击事件
fun setOnNotificationClickListener(listener: OnNotificationClick) {
mListener = listener
}

interface OnNotificationClick {
fun onClick()
}
}

Dialog的主题


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

<style name="dialog_notifacation_top">
<item name="android:windowIsTranslucent">true</item>
<!--设置背景透明-->
<item name="android:windowBackground">@android:color/transparent</item>
<!--设置dialog浮与activity上面-->
<item name="android:windowIsFloating">true</item>
<!--去掉背景模糊效果-->
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowNoTitle">true</item>
<!--去掉边框-->
<item name="android:windowFrame">@null</item>
</style>


<style name="dialog_animation" parent="@android:style/Animation.Dialog">
<!-- 进入时的动画 -->
<item name="android:windowEnterAnimation">@anim/dialog_enter</item>
<!-- 退出时的动画 -->
<item name="android:windowExitAnimation">@anim/dialog_exit</item>
</style>

</resources>

Dialog的动画


<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="600"
android:fromYDelta="-100%p"
android:toYDelta="0%p" />
</set>

<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="300"
android:fromYDelta="0%p"
android:toYDelta="-100%p" />
</set>

Dialog的布局,通CardView包裹一下就有立体阴影的效果


<androidx.cardview.widget.CardView
android:id="@+id/cd"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/size_15dp"
app:cardCornerRadius="@dimen/size_15dp"
app:cardElevation="@dimen/size_15dp"
app:layout_constraintTop_toTopOf="parent">

<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/et_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/size_15dp"
app:layout_constraintTop_toTopOf="parent">

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#000000"
android:textSize="@dimen/font_14sp" android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/size_15dp"
android:textColor="#333"
android:textSize="@dimen/font_12sp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_title" />


</androidx.constraintlayout.widget.ConstraintLayout>

</androidx.cardview.widget.CardView>

二、获取当前显示的Activity的弱引用


/**
* 前台Activity管理类
*/
class ForegroundActivityManager {

private var currentActivityWeakRef: WeakReference<Activity>? = null

companion object {
val TAG = "ForegroundActivityManager"
private val instance = ForegroundActivityManager()

@JvmStatic
fun getInstance(): ForegroundActivityManager {
return instance
}
}


fun getCurrentActivity(): Activity? {
var currentActivity: Activity? = null
if (currentActivityWeakRef != null) {
currentActivity = currentActivityWeakRef?.get()
}
return currentActivity
}


fun setCurrentActivity(activity: Activity) {
currentActivityWeakRef = WeakReference(activity)
}

}

监听所有Activity的生命周期


class AppLifecycleCallback:Application.ActivityLifecycleCallbacks {

companion object{
val TAG = "AppLifecycleCallback"
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
//获取Activity弱引用
ForegroundActivityManager.getInstance().setCurrentActivity(activity)
}

override fun onActivityStarted(activity: Activity) {
}

override fun onActivityResumed(activity: Activity) {
//获取Activity弱引用
ForegroundActivityManager.getInstance().setCurrentActivity(activity)
}

override fun onActivityPaused(activity: Activity) {
}

override fun onActivityStopped(activity: Activity) {
}

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
}

override fun onActivityDestroyed(activity: Activity) {
}
}

在Application中注册


//注册Activity生命周期
registerActivityLifecycleCallbacks(AppLifecycleCallback())

三、封装和使用


/**
* 通知的管理类
* example:
* //发系统通知
* NotificationControlManager.getInstance()?.notify("文件上传完成", "文件上传完成,请点击查看详情")
* //发应用内通知
* NotificationControlManager.getInstance()?.showNotificationDialog("文件上传完成","文件上传完成,请点击查看详情",
* object : NotificationControlManager.OnNotificationCallback {
* override fun onCallback() {
* Toast.makeText(this@MainActivity, "被点击了", Toast.LENGTH_SHORT).show()
* }
* })
*/

class NotificationControlManager {

private var autoIncreament = AtomicInteger(1001)
private var dialog: NotificationDialog? = null

companion object {
const val channelId = "aaaaa"
const val description = "描述信息"

@Volatile
private var sInstance: NotificationControlManager? = null


@JvmStatic
fun getInstance(): NotificationControlManager? {
if (sInstance == null) {
synchronized(NotificationControlManager::class.java) {
if (sInstance == null) {
sInstance = NotificationControlManager()
}
}
}
return sInstance
}
}


/**
* 是否打开通知
*/
fun isOpenNotification(): Boolean {
val notificationManager: NotificationManagerCompat =
NotificationManagerCompat.from(
ForegroundActivityManager.getInstance().getCurrentActivity()!!
)
return notificationManager.areNotificationsEnabled()
}


/**
* 跳转到系统设置页面去打开通知,注意在这之前应该有个Dialog提醒用户
*/
fun openNotificationInSys() {
val context = ForegroundActivityManager.getInstance().getCurrentActivity()!!
val intent: Intent = Intent()
try {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS

//8.0及以后版本使用这两个extra. >=API 26
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, context.applicationInfo.uid)

//5.0-7.1 使用这两个extra. <= API 25, >=API 21
intent.putExtra("app_package", context.packageName)
intent.putExtra("app_uid", context.applicationInfo.uid)

context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()

//其他低版本或者异常情况,走该节点。进入APP设置界面
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.putExtra("package", context.packageName)

//val uri = Uri.fromParts("package", packageName, null)
//intent.data = uri
context.startActivity(intent)
}
}

/**
* 发通知
* @param title 标题
* @param content 内容
* @param cls 通知点击后跳转的Activity,默认为null跳转到MainActivity
*/
fun notify(title: String, content: String, cls: Class<*>) {
val context = ForegroundActivityManager.getInstance().getCurrentActivity()!!
val notificationManager =
context.getSystemService(AppCompatActivity.NOTIFICATION_SERVICE) as NotificationManager
val builder: Notification.Builder
val intent = Intent(context, cls)
val pendingIntent: PendingIntent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
} else {
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel =
NotificationChannel(channelId, description, NotificationManager.IMPORTANCE_HIGH)
notificationChannel.enableLights(true);
notificationChannel.lightColor = Color.RED;
notificationChannel.enableVibration(true);
notificationChannel.vibrationPattern =
longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
notificationManager.createNotificationChannel(notificationChannel)
builder = Notification.Builder(context, channelId)
.setSmallIcon(R.drawable.jpush_notification_icon)
.setContentIntent(pendingIntent)
.setContentTitle(title)
.setContentText(content)
} else {
builder = Notification.Builder(context)
.setSmallIcon(R.drawable.jpush_notification_icon)
.setLargeIcon(
BitmapFactory.decodeResource(
context.resources,
R.drawable.jpush_notification_icon
)
)
.setContentIntent(pendingIntent)
.setContentTitle(title)
.setContentText(content)

}
notificationManager.notify(autoIncreament.incrementAndGet(), builder.build())
}


/**
* 显示应用内通知的Dialog,需要自己处理点击事件。listener默认为null,不处理也可以。dialog会在3000毫秒后自动消失
* @param title 标题
* @param content 内容
* @param listener 点击的回调
*/
fun showNotificationDialog(
title: String,
content: String,
listener: OnNotificationCallback? = null
) {
val activity = ForegroundActivityManager.getInstance().getCurrentActivity()!!
dialog = NotificationDialog(activity, title, content)
if (Thread.currentThread() != Looper.getMainLooper().thread) { //子线程
activity.runOnUiThread {
showDialog(dialog, listener)
}
} else {
showDialog(dialog, listener)
}
}

/**
* show dialog
*/
private fun showDialog(
dialog: NotificationDialog?,
listener: OnNotificationCallback?
) {
dialog?.showDialogAutoDismiss()
if (listener != null) {
dialog?.setOnNotificationClickListener(object :
NotificationDialog.OnNotificationClick {
override fun onClick() = listener.onCallback()
})
}
}

/**
* dismiss Dialog
*/
fun dismissDialog() {
if (dialog != null && dialog!!.isShowing) {
dialog!!.dismiss()
}
}


interface OnNotificationCallback {
fun onCallback()
}

}

另外需要注意的点是,因为dialog是延迟关闭的,可能用户立刻退出Activity,导致延迟时间到时dialog退出时报错,解决办法可以在BaseActivity的onDestroy方法中尝试关闭Dialog:


override fun onDestroy() {
super.onDestroy()
NotificationControlManager.getInstance()?.dismissDialog()
}

作者:TimeFine
链接:https://juejin.cn/post/7119049874175164453
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android Studio Debug:编码五分钟,调试俩小时

前言 整理并积累Android开发过程中用到的一些调试技巧,通过技巧性的调试技能,辅助增强代码的健壮性、安全性、正确性 案例一:抛出明显异常 常见的:除数为0问题 class MainActivty : AppCompatActivity(){ o...
继续阅读 »

前言


整理并积累Android开发过程中用到的一些调试技巧,通过技巧性的调试技能,辅助增强代码的健壮性、安全性、正确性


案例一:抛出明显异常



  • 常见的:除数为0问题


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
val i = 1/0
}
}
}

image.png



会提示错误原因,并告知在哪一行




  • 一般错误


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
val s = "Candy" //假设此处是在一个方法内,我们无法看到
var i = 0
i = s.toInt()
}
}
}

image.png



会提示错误原因,并告知在哪一行


错误原因可能不认识,直接找错误关键字,检索百度



案例二:逻辑问题



  • println()方式调试


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
var res = 0
for (i in 1 until 10){
res=+i //该处将逻辑故意写错 应为 +=
println("i=${i},res=${res}")
}
val s:String = StringTo(res)
Toast.makeText(this,s,Toat.LENGTH.SHORT).show()
}
}
private fun StringTo(res:String){
println("将Int转换成String")
resturn res.roString()
}
}

image.png



会掺杂其他方法日志




  • log方式调试


class MainActivty : AppCompatActivity(){
val TAG = "MainActivity"
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
var res = 0
for (i in 1 until 10){
res=+i //该处将逻辑故意写错 应为 +=
Log.d(TAG,"i=${i},res=${res}")
}
val s:String = StringTo(res)
Toast.makeText(this,s,Toat.LENGTH.SHORT).show()
}
}
private fun StringTo(res:String){
println("将Int转换成String")
resturn res.roString()
}
}

image.png



筛选条件多:Debug、Info、Worn、Error以及自定义筛选等


可以直接根据key筛选


调试数据较多时,不方便查看,不够灵活




  • debug模式调试


image.png



  • resume progrem: 继续执行

  • step over: 跳入下一行

  • step into: 进入自定义方法,非方法则下一行

  • force step into:进入所有方法,非方法则下一行

  • step out: 跳出方法,且方法执行完成

  • run to cursor: 跳入逻辑的下一个标记点
    image.png



debug运行时,会出现提示框,无需操作



案例三:代码丢失||项目问题



  • history

    • 不小心删除代码/文件且已save并退出
      右击项目 -> Local History -> Show History -> 选择某一历史右键 -> Revert




image.png


image.png


image.png


image.png


image.png


作者:Coward881
链接:https://juejin.cn/post/7114926062982660103
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

vivo官网APP全机型UI适配方案

日益新增的机型,给开发人员带来了很多的适配工作。代码能不能统一、apk 能不能统一、物料如何选取、样式怎么展示等等都是困扰开发人员的问题,本方案就是介绍不同机型的共线方案,打消开发人员的疑虑。一、日益纷繁的机型带来的挑战1.1  背景科技是进步的,人...
继续阅读 »

日益新增的机型,给开发人员带来了很多的适配工作。代码能不能统一、apk 能不能统一、物料如何选取、样式怎么展示等等都是困扰开发人员的问题,本方案就是介绍不同机型的共线方案,打消开发人员的疑虑。


一、日益纷繁的机型带来的挑战


1.1  背景


科技是进步的,人们对美的要求也是逐渐提升的,所以才有了现在市面上形形色色的机型


(1)比如 vivo X60 手机采用纤薄曲面屏设计,属于直板机型。



(2)比如 vivo 折叠屏高端手机,提供更优质的视觉体验,属于折叠屏机型。



(3)比如 vivo pad,拥有优秀的操作手感和高级的质感,属于平板机型。



1.2  我们的挑战


在此之前,我们主要是为直板手机去服务,我们的开发只要适配这种主流的直板机器,我们的 UI 主要去设计这种直板手机的效果图,我们的产品和运营主要为这种直板机型去选择物料。



可是随着这种形形色色机型的出现,那么问题就来了:

(1)开发人员的适配成本高了,是不是针对每一种机型,都要做个单独的应用进行适配呢?

(2)UI 设计师要做的效果图要多了,是不是要针对每种机型都要设计一套效果图呢?

(3)产品和运营需要选择的物料更受限制了,会不会这个物料在一个机器上正常。在其他机器上就不正常了呢?


为什么这么说,下面以开发者的角度来做介绍,把我们面临的问题,做说明。


二、 开发者的窘境


2.1 全机型适配成本太高


日渐丰富的机型适配让我们这些 android 开发人员疲于奔命,虽然可以按照要求进行适配,但是大屏幕的机型适配成本依然比较高,因为这些机型不同于传统的直板手机的宽高比例(9:16)。所以有的应用干脆就直接两边留白,内容区域展示在屏幕正中央,这种效果,当然很差。

案例 1:某个视频 APP 页面,未做 pad 上的适配,打开之后的效果如下,两边大量留白,是不可操作的区域。


案例 2:某新闻资讯类 APP,在 pad 上的适配效果如下,可见的范围内,信息流展示内容较少,图片有拉伸、模糊的问题。



2.2 全机型适配成本高在哪


上面的案例其实只是表面的问题之一,作为开发人员,需要考虑的因素有很多,首先要想到这些机型有什么特点:


然后才是需要解决的问题:



三、寻找全机型适配方案之旅


3.1 方案讨论与确定


页面拉伸、左右留白是现象,这也是用户的直接体验。那么这就是我们要改善的地方,所以现在就有方向了,围绕着 “如何在可见区域内,展示更多的信息” 。这不是布局的简单重新排列组合,因为  方案绝对不是只有开发决定如何实现就可以怎么实现的,一个 apk 承载着功能到用户手里涉及了多方角色的介入。产品经理需要整理需求、运营人员需要配置物料、发布 apk,测试需要测试等等,所以最终的方案不是一方定下来的,而是一个协调统一后的结果。


既然要去讨论方案,那么就要有依据,在此省略讨论、评审、定稿的过程。


先来看看直板、折叠屏、pad 的外部轮廓图,知道页面形态如何。



3.2 方案落地示意图


每个应用要展示的内容不一致,但是原理一致,此处就以下面几个样式为基础介绍原理。原则也比较简单,尽可能展示更多内容,不要出现大面积的空白区域。


下面没有介绍分栏模式的适配,因为分栏的模式也可能被用户关闭,最终成为全屏模式,所以说,可以选择只适配全屏模式,这样的适配成本较低。当然,这个也要根据自己模块的情况来确定,比如微信,更适合左右屏的分栏模式。


3.2.1 直板机型适配方案骨骼图


直板机型,目前主流的机型,宽高比基本是 9:16,可以最大限度地展示比较多的内容,比如下图中的模块 1、模块 2、 模块 3 的图片。



3.2.2 折叠屏机型适配方案骨骼图


折叠屏机型,屏幕可旋转,但是宽高比基本是 1:1,高度和直板机器基本差不多,可以达到 2000px 的像素,所以在纵向上,也可以最大限度地展示比较多的内容,比如下图中的模块 1、模块 2、 模块 3 的图片。



3.2.3 PAD 机型适配方案骨骼图


pad 平板,屏幕可旋转,并且旋转后的宽高比差异较大,纵向时,宽高比是 5 : 8,横向时,宽高比是 8 : 5。


在 pad 纵向时,其实高度像素是足够展示很多内容的,比如下图中的模块 1、模块 2、 模块 3 的图片;


但是在 pad 横向时,没办法展示更多的内容(倒是有个方案,最后再说),只能下图中的模块 1、模块 2 的图片。



3.3 方案落地规范


3.3.1 一套代码适配所有机型


确定一个 apk 能不能适配所有机型,首先要解决的是要符合不同机型的特性,比如直板手机只能纵向显示,折叠屏和 pad 支持横竖屏旋转。


描述如下:


(1)需求

  • 直板屏:强制固定竖屏;

  • 折叠屏:外屏固定竖屏、内屏 (大屏) 支持横竖屏切换;

  • PAD 端:支持横竖屏切换;

我们需要在以上三端通过一套代码实现上面的需求。


(2)横竖屏切换

有以下 2 种方法:
方式 1)

通过在 AndroidManifest.xml 中设置:

android:screenOrientation 属性
a) android:screenOrientation="portrait" 

强制竖屏;
b) android:screenOrientation="landscape" 

强制横屏;
c) android:screenOrientation="unspecified" 

默认值,可以横竖屏切换;


方式 2)

在代码中设置:

activity.setRequestedOrientation(****);
a) setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);    设置竖屏;

b)setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); 设置横屏;
c)setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); 可以横竖屏切换;


(3)不同设备支持不同的屏幕横竖屏方式


1)直板屏:

因为是强制竖屏,所以,可以通过在 AndroidManifest.xml 中给 Activity 设置 android:screenOrientation="portrait"。


2)折叠屏:

外屏与直板屏是保持一致的,暂且不讨论。但是内屏 (大屏) 要支持横竖屏切换。如果是一套代码,显然是无法通过 AndroidManifest 文件来实现的。这里其实系统框架已经帮我们实现了对应内屏时横竖屏的逻辑。总结就是,折叠屏可以与直板屏保持一致,在 AndroidManifest.xml 中给 Activity 设置 android:screenOrientation="portrait",如果切换到内屏时,系统自动忽略掉 screenOrientation 属性值,强行支持横竖屏切换。


3)PAD 端:

当然了,并不是所有的项目对应的系统都会自动帮我们忽略 screenOrientation 属性值,这时候就需要我们自己来实现了。


我们通过在 Activity 的基类中设置 setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED),发现确实能够使当前页面横竖屏自由切换了。但是在启动 activity 的时候遇到了问题。当我们从横屏状态 A 界面启动一个 acitivity 的 B 界面时,发现 B 界面先是竖屏,然后切换到了横屏(如图 1 所示)。再试了多次依旧如此,肉眼可见的切换过程显然不能满足我们的需求。这说明通过 java 代码动态调整横竖屏的技术方向是行不通的。综上所述,通过同一份代码无法满足 PAD 端和直板屏的互斥的需求。



那还有没有其他方式呢。别忘了,我们 Android 打包全流程是通过 gradle 完成的,我们是不是可以通过切面编程的思维,针对不同的设备打出不同的包。


方案确定了,在此进行技术验证。


gradle 编译其中一个重要环节就是对依赖的 aar、本地 module 中的 AndroidManifest 文件进行 merge,最终输出一份临时的完整清单文件,存放在 */app/build/intermediates/merged_manifest/**Release / 路径下。


因此,我们可以在 AndroidManifest 文件 merge 完成之后对该临时文件中的 android:screenOrientation 字段值信息进行动态修改,修改完成之后再存回去。这样针对 pad 端就可以单独打出一份 apk 文件。


核心代码如下:

//pad支持横竖屏
def processManifestTask = project.tasks.getByName("processDefaultNewSignPadReleaseManifest");
if (processManifestTask != null) {
processManifestTask.doLast { pmt ->
def manifestPath = pmt.getMultiApkManifestOutputDirectory().get().toString() + "/AndroidManifest.xml"
if (new File(manifestPath).exists()) {
String manifest = file(manifestPath).getText()
manifest = manifest.replaceAll("android:screenOrientation=\"portrait\"", "android:screenOrientation=\"unspecified\"");
file(manifestPath).write(manifest)
println(" =============================================================== manifestPath: " + manifestPath)
}
}
}


(4)apk 的数量


到这里为止,java 代码是完全一致,没有区分的,关键就在于框架有没有提供出忽略 screenOrientation 的能力,如果提供了,我们只需要输出一个 apk,就能适配所有机型,


如果没有这个能力,我们就需要使用 gradle 打出额外的一个 apk,满足可旋转的要求。


3.3.2 一套物料配所有机型


1、等比放大物料

通过上面的落地方案的要求,对于模块 2 的图片,展示效果是不一样的,如下图:

(1)直板手机上面,模块 2 的图片 1 在上面,图片 2、3 分布于左下角和右下角

(2)折叠屏或者 pad 上面,模块 2 的图片 1 在左边,图片 2、3 分布于右侧

(3)折叠屏和 pad 上的模块 2 的图片,相对于直板手机来说,做了样式的调整,上下的样式改为了左右。图片也做了对应的放大,保证横向上可以填充整个屏幕的宽度。



(4)为了形象地表示处理后的效果,看下下面的示意图即可。



2、高度不变,裁剪物料


对于模块 3 的图片,可以回顾 3.2 中的展示样式,要求是

(1)直板手机上面,模块 3 中图片 1 的高度此处为 300px。

(2)折叠屏或者 pad 上面,模块 3 的图片 1 的高度也是 300px,但是内容不能减少。

(3)解决方案就是提供一张原始大图,假如规格为 2400px*300px,在直板手机上左右进行裁剪,如下图所示。折叠屏和 pad 上面直接进行展示。而裁剪这一步,放在服务端进行,因为客户端做裁剪,比较耗时。


(4)为了形象地表示处理后的效果,看下下面的示意图即可。



3.3.4 无感刷新


无感刷新,主要是体现在折叠屏的内外屏切换,pad 的横竖屏旋转这些场景,如何保证页面不会出现切换、旋转时候的闪现呢?

(1)这就要提前准备好数据源,保证在页面变化时,立即 notify。

(2)我们的页面列表最好使用 recyclerview,因为 recyclerview 支持局部刷新。

(3)数据源驱动 UI,千万不要在 UI 层面判断机型做 UI 的动态计算,页面会闪屏,体验不好。



3.4 方案落地实战


上面介绍了不同机型的适配规范,这个没有疑问之后,直接通过案例来看下具体如何实施。



如上图所示,选购页可以大致分为 分类导航栏区域 和 内容区域,其中内容区域是由多个楼层组成。


3.4.1 UI 如何设计的



如图所示,能够直观地感受到,从直板手机到折叠屏内屏再到 Pad 横屏,当设备的可显示面积增大时,页面充分利用空间展示更多的商品信息。


3.4.2 不同设备的区分方式


通过前面的简单介绍,对选购页的整体布局及不同设备上的 UI 展示有所了解,下面来看下如何在多个设备上实现一套代码的适配。


首先第一步,要如何区分不同的设备。

在区分不同的设备前,先看下能够从设备中获得哪些信息?

1)分辨率

2)机型

3)当前屏幕的横、竖状态


先说结论:

  • 直板手机:通过分辨率来区分

  • 折叠屏:通过机型和内外屏状态来区分

  • Pad:通过机型和当前屏幕的横、竖状态来区分


所以这里根据这几个特点,提供一个工具。

不同设备的区分方式。

/** * @function 判断当前手机的屏幕是处于哪个屏幕类型:目前三个屏幕范围:分别为 <= 528dp、528 ~ 696dp、> 696dp,对应的分别是正常直板手机、折叠屏手机内屏和Pad竖屏、和Pad横屏 */public class ScreenTypeUtil {     public static final int NORMAL_SCREEN_MAX_WIDTH_RESOLUTION = 1584; // 正常直板手机:屏幕最大宽度分辨率;Pad的分辨率(1600*2560), 1584 = 528 * 3 528dp是UI在精选页标注的直板手机范围    public static final int MIDDLE_SCREEN_MAX_WIDTH_RESOLUTION = 2088; // 折叠屏手机:屏幕最大宽度分辨率(1916*1964, 旋转:1808*2072),2088 = 696 * 3 2088dp是UI在精选页标注的折叠屏展开范围    public static final int LARGE_SCREEN_MAX_WIDTH_RESOLUTION = 2560; // 大屏幕设备:屏幕宽度暂定为 Pad的高度     public static final int NORMAL_SCREEN = 0; // 正常直版手机屏幕    public static final int MIDDLE_SCREEN = 1; // 折叠屏手机内屏展开、Pad竖屏    public static final int LARGE_SCREEN = 2;  // Pad横屏     public static int getScreenType() {        Configuration configuration = BaseApplication.getApplication().getResources().getConfiguration();        return getScreenType(configuration);    }     // 注意这里的newConfig 在Activity、Fragment、View 中的onConfigurationChanged中获得的newConfig传入,如果获得不了该值,可以使用getScreenType()方法    public static int getScreenType(@NonNull Configuration newConfig) {        // Pad 通过机型标志位及当前处于横竖屏状态 来判断当前屏幕类型        if (SystemInfoUtils.isPadDevice()) {            return newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ? LARGE_SCREEN : MIDDLE_SCREEN;        }        // Fold折叠屏 通过机型标志及内外屏状态 来判断当前屏幕类型        if (SystemInfoUtils.isFoldableDevice()) {            return SystemInfoUtils.isInnerScreen(newConfig) ? MIDDLE_SCREEN : NORMAL_SCREEN;        }        // 普通手机 通过分辨率判断        return AppInfoUtils.getScreenWidth() <= NORMAL_SCREEN_MAX_WIDTH_RESOLUTION ? NORMAL_SCREEN : (AppInfoUtils.getScreenWidth() <= MIDDLE_SCREEN_MAX_WIDTH_RESOLUTION ? MIDDLE_SCREEN : LARGE_SCREEN);    }}


3.4.3 实现方案


(1)数据源驱动 UI 改变的思想


对于直板手机来说,选购页只有一种状态,保持竖屏展示

对于折叠屏来说,折叠屏可以由内屏切换到外屏,也就涉及到了两种不同状态的切换。


对于 Pad 来说,Pad 支持横竖屏切换,所以也是两种不同状态切换。


当屏幕类型、横竖屏切换、内外屏切换时,Activity\Fragment\View 会调用 onConfigurationChanged 方法,因此针对直板手机、折叠屏及 Pad 可以将数据源的切换放在此处。


无论是哪种设备,最多是只有两种不同的状态,因此,数据源这里可以准备两套:一种是 Normal、一种是 Width,对直板手机而言:因为只有一种竖屏状态,因此只需要一套数据源即可;对折叠屏而言:Normal 存放的是折叠屏外屏数据源,Width 存放的是折叠屏内屏数据源;对 Pad 而言:Normal 存放的是 Pad 竖屏状态数据源,Width 存放的是 Pad 横屏状态数据源。


(2)内容区域


右侧的内容区域是一个 Fragment,在这个 Fragment 里面包含了一个 RecyclerView。


每个子楼层

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:id="@+id/root_classify_horizontal"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:orientation="vertical">     <xxx.widget.HeaderAndFooterRecyclerView        android:id="@+id/shop_product_multi_rv"        android:layout_width="match_parent"        android:layout_height="wrap_content" /> LinearLayout>


每个楼层也是一个单独的 RecyclerView,以楼层 4 为例,楼层 4 的每一行商品都是一个 RecyclerView,每个 RecyclerView 使用 GridLayoutManager 来控制布局的展现列数。


(3)数据源


以折叠屏为例:针对每个子楼层的数据,在解析时,就先准备两套数据源:一种是 Normal、一种是 Width。


在请求网络数据回来后,在解析数据完成后,存放两套数据源。这两套数据源要根据 UI 设计的规则来组装,例如以折叠屏的楼层 4 为例:

折叠屏 - 外屏 - 楼层 4:一行展示 2 个商品信息。

折叠屏 - 内屏 - 楼层 4:一行展示 3 个商品信息。


注意:这里的 2、3 数字是 UI 设计之初就定下来的,每行商品都是一个 RecyclerView,并且使用 GridLayoutManager 来控制其列数,因此这个 2、3 也是传入到 GridLayoutManager 的列数值,这里要保持一致。


子楼层的数据源解析

//这里的normalProductMultiClassifyUiBeanList集合中存放了2个商品信息for (ProductMultiClassifyUiBean productMultiClassifyUiBean : normalProductMultiClassifyUiBeanList) {    productMultiClassifyUiBean.setFirstFloor(isFirstFloor);    shopListDataWrapper.addNormalBaseUiBeans(productMultiClassifyUiBean);}//这里的normalProductMultiClassifyUiBeanList集合中存放了3个商品信息for (ProductMultiClassifyUiBean productMultiClassifyUiBean : widthProductMultiClassifyUiBeanList) {    productMultiClassifyUiBean.setFirstFloor(isFirstFloor);    shopListDataWrapper.addWidthBaseUiBeans(productMultiClassifyUiBean);}


因此,到这里就已经获取了所需的数据源部分


(4)屏幕类型切换

还是以折叠屏为例,折叠屏外屏切换到内屏,此时 Fragment 会走 onConfigurationChanged 方法。


屏幕类型切换 - 数据源切换 - 更新 RecyclerView。

public void onConfigurationChanged(@NonNull Configuration newConfig) {    super.onConfigurationChanged(newConfig);    //1、 首先进行内容区域中的RecyclerViewAdapter、数据源判空    if (mRecyclerViewAdapter == null || mPageBeanAll == null) {        return;    }    //2、判断当前的屏幕类型,注意:这个地方是调用3提供的方法:ScreenTypeUtil.getScreenType(newConfig)    // 直板手机、折叠屏外屏    if (ScreenTypeUtil.NORMAL_SCREEN == ScreenTypeUtil.getScreenType(newConfig)) {        mPageBeanAll.setBaseUiBeans(mPageBeanAll.getNormalBaseUiBeans());    } else if (ScreenTypeUtil.MIDDLE_SCREEN == ScreenTypeUtil.getScreenType(newConfig)) {        if (SystemInfoUtils.isPadDevice()) {            // Pad的竖屏            mPageBeanAll.setBaseUiBeans(mPageBeanAll.getNormalBaseUiBeans());        } else {            // 折叠屏的内屏            mPageBeanAll.setBaseUiBeans(mPageBeanAll.getWidthBaseUiBeans());        }    } else {        // Pad的横屏、大分辨率屏幕        mPageBeanAll.setBaseUiBeans(mPageBeanAll.getWidthBaseUiBeans());    }    //获取当前屏幕类型的最新数据源    mRecyclerViewAdapter.setDataSource(mPageBeanAll.getBaseUiBeans());    //数据源驱动楼层UI改变    mRecyclerViewAdapter.notifyDataSetChanged();}


通过 onConfigurationChanged 方法,能够看到数据源是如何根据不同屏幕类型进行切换的,当数据源切换后,会通过 notifyDataSetChanged 方法来改变 UI。


四、至简之路的铸就


大道至简,遵循规范和原则,就可以想到如何对多机型进行适配,别陷入细节。


以这个作为指导思想,可以做很多其他的适配。下面做些列举,但不讲解实现方式了。


1、文字显示区域放大

如下图所示,标题的长度,在整个容器显示宽度变宽的同时,也跟着一起变化,保证内容的长度可以自适应的变化。


2、弹框样式的兼容

如下图所示,蓝色区域是键盘的高度,在屏幕进行旋转的时候,键盘的高度也是变化的,此时可能会出现遮挡住原本展示的内容,此处的处理方式是:让内容区域可以上下滑动。


3、摄像头位置的处理

如下图所示,在屏幕旋转之后,摄像头可以出现在右下角,此时如果不对页面进行设置,那么就可能出现内容区域无法占据整个屏幕区域的问题,体验比较差,此处的处理方式是:设置页面沉浸式,摄像头可以合理地覆盖一部分内容。



五、我们摆脱困扰了吗


5.1 解决原先的问题


通过前面的介绍,我们知道了,vivo 官网的团队针对折叠屏和 pad 这种大屏,采取了全屏展示的方案,一开始的时候,我们遇到的问题也得到了解决:


(1)开发人员的适配成本高了,是不是针对每一种机型,都要做个单独的应用进行适配呢?

Answer:按照全屏模式的设计方案,折叠屏和 pad 也就是一种大尺寸的机器,开发人员判断机型的分辨率和尺寸,选择一种对应的布局展示就好了,只用一个应用就能搞定。


(2)UI 设计师要做的效果图要多了,是不是要针对每种机型都要设计一套效果图呢?

Answer:制定一套规范,大于某个尺寸时,展示其他样式,所有信息内容都按照这种规范来,不会出现设计混乱的情况。


(3)产品和运营需要选择的物料更受限制了,会不会这个物料在一个机器上正常。在其他机器上就不正常了呢?

Answer:以不变应万变,使用一套物料,适配不同的机型已经可以落地了,不用再担心在不同的机器上展示不统一的问题。


5.2 我们还可以做什么


5.2.1 我们的优点


折叠屏和 pad 两款机器,已经在市面上使用较长时间,各家厂商也纷纷采取了不同的适配方案来提升交互体验,但是往往存在下面几个问题:


1、针对不同机型,采用了不同的安装包。

这种方案,其实会增加维护成本,后期的开发要基于多个安装包去开发,更加耗时。


2、适配了不同的机型,但是在一些场景下的样式不理想。

比如有些 APP 做了分栏的适配,但是没有做全屏的适配,效果就比较差,这里可能也是考虑到了投入产出比。


3、目前的适配指导文档对于开发人员来说指导性较弱。

各种适配指导文档,还是比较偏向于官方,对于开发人员来说,还是无法提前识别问题,遇到问题还是要实际去解决,

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


基于此,我们的优点如下:


1、我们只有一个安装包。

我们是一个安装包适配所有机型,每种机型的 APP 展示的样式虽然不同,对于开发者来说,就是增加了一个样式,思路比较清晰。


2、全场景适配。

不同机型的纵向、横竖屏切换,都做到了完美适配,一套物料适配所有机型也是我们的一个特色。


3、有针对性地提供适配方案。

本方案是基于实际开发遇到的问题,进行的梳理,可以帮忙开发人员解决实际可能遇到的问题,具备更好的参考性。


5.2.2 我们还有什么要改进


回首方案,我们这里做到的是使用全屏模式去适配不同机型,更多的适用于像京东、淘宝、商城等电商类 APP 上,实际上,现在有些非 APP 会采用分栏的形式做适配,这也是一种跟用户交互的方式,本方案没有提到分栏,后续分栏落地后,对这部分会再进行补充。


作者:vivo 互联网客户端团队- Xu Jie 

收起阅读 »

奇葩!一公司面试题竟问如厕习惯、吃饭时长、入睡时间等

为了更好地了解求职者的个人情况,公司面试官在与求职者交流之前,往往会让他们填写一些面试题。但最近有网友曝光了长沙一家公司的面试题,题目奇葩、详细到让人感到很“惊悚”。 1 面试题涉及个人隐私,公司:可填可不填据@正观视频报道,这家公司一共设置了15道题,包含哲...
继续阅读 »

为了更好地了解求职者的个人情况,公司面试官在与求职者交流之前,往往会让他们填写一些面试题。但最近有网友曝光了长沙一家公司的面试题,题目奇葩、详细到让人感到很“惊悚”。

1 面试题涉及个人隐私,公司:可填可不填

据@正观视频报道,这家公司一共设置了15道题,包含哲学、数学、日常生活等方面。其中提到了吃饭时长、入睡时间、如厕习惯等问题,甚至详细到“日常如大厕一般在家还是在外、有无规律、用时多久”等涉及个人隐私的内容。


招聘员工需要了解这么多个人隐私内容吗?不少网友表示实在是匪夷所思:

  • “这是在想怎么压榨员工的吧”

  • “吃饭睡觉都问,你没事吧”

  • “还问这些……什么公司哦!奇葩”

据记者从涉事公司了解到,这是一家商务信息咨询公司,主要从事汽车后市场领域。一名工作人员表示,该面试题是通用测试题,没有标准答案。一个人日常的生活行为肯定与其工作的行为息息相关(比如生活习性、行为逻辑和思维架构等),公司希望通过这些题从不同维度了解一个人,综合得出其个人基本情况。

当被问及题目是否涉及隐私时,工作人员回复称,面试者可以填也可以不填,把这些题往“个人隐私”上扣的话,“只有没事做、或者矫情,生活中不怎么样的人才会这样想,现实中没人会关心陌生人这些信息。”公司这套题下来最终是为了达到一个效果,让员工能够以自我驱动,不需要公司真正的管理。

工作人员还表示,面试者普遍对这套题持欢迎态度,也有极个别人觉得题目与自己的技术和工作没有关系。对于公司这一做法,有律师表示,求职者在应聘时遇到涉及隐私的不当问题,有权拒绝回答。

2 程序员面试时遇到过的奇葩问题

除了像这家公司让面试者填写涉及个人隐私问题的操作外,在广大程序员群体中,也有不少人分享了自己在面试时遇到的奇葩问题:

  • “面试时被问为什么电脑屏幕是方的,而不是其他形状?”

  • “面试XX公司安卓岗,面试官叫我写出某段JS代码的机器码”

  • “面试初创公司,HR丢了一支笔和一张A4,让我一个小时写出一个APP,还说简单点就可以。”

  • “面试找实习,问我开发一个程序和种苹果有什么关系……”

当然,对于程序员来说,也许最讨厌的就是被问“会不会修电脑”。

最后,你怎么看待这家公司的面试题?你在面试时有遇到过什么奇葩问题吗?欢迎留言~

参考链接:

来源:程序人生

收起阅读 »

对移动端app容灾的思考

移动端app容灾 可能很多人对这个概念比较陌生,我们常说的容灾策略,一般都特指服务器端的容灾,那么移动端容灾是个啥!其实跟服务器一样,就是持续保证我们app的可用性,在crash或者anr的时候,能够通过一些手段实现保证后续可用。 本篇不涉及复杂技术,更多的是...
继续阅读 »

移动端app容灾


可能很多人对这个概念比较陌生,我们常说的容灾策略,一般都特指服务器端的容灾,那么移动端容灾是个啥!其实跟服务器一样,就是持续保证我们app的可用性,在crash或者anr的时候,能够通过一些手段实现保证后续可用。


本篇不涉及复杂技术,更多的是对方案的探讨,请放心食用!


为什么会有这个概念


其实在笔者角度上看,技术与业务的关系其实是比较单一的,虽然不至于对立,但是一个业务人员看待技术,最关心的可能就是稳定性了,在“老板”角度上看,他其实不太关心所用的技术是什么,但是一定关心这个服务能不能保证自己的业务能不能持续,这也是笔者访谈了几位非技术人员得出的结论,同时在“降本增效”的今天,追求稳定性可能是大部分公司的选择了。还有就是站在长远立场上看,移动端的容灾也慢慢会成为各大公司角逐的一个点。一个由于crash导致而离开的用户,就有可能带走10个相关联客户,在app场景如此,在游戏场景也是,如果打着游戏突然闪退了,肯定是一个非常不好的体验。


本文希望介绍一些移动端的容灾策略,希望能够给各大开发者提供一个启发。


容灾策略


降级


首先是第一个策略,降级,比如app crash的时候,我们采用降级的手段,转移到h5页面


image.png


这个方案的特点是 存在两套页面,一个是原生页面,一个是h5页面,大部分公司可能都会同时有这两套ui,一个用于投放app,一个用于h5页面,比如网页还有m站这些。
当主页(也可以是特定activity),跳转到其他页面时,如果发生了crash,就从主页直接打开h5容器,展示h5页面,这个在拼*多app方案上常用


进程多活


android在多进程上给我们提供了很多便捷的地方,只需要在activity或者其他的manifest文件上声明process即可


android:process=":test"

一般我们不做特殊配制的话,activity等就是运行在以包名为名称的进程上。这里的多进程方案有两个



  • app crash的时候,通过安全气囊机制,重新为用户打开到当前页面,即我们会杀掉原本的进程,重新打开一个新进程,并为用户定位到当前页,可以携带本地的tag或者其他标识进行页面的定位,这个方案可以运用在游戏中,如果crash了立马主动帮用户重开,并提高这部分用户的载入速度!


image.png



  • (这也是我最推荐的)app crash/anr的时候,不重新进入原页面,而是通过安全气囊机制,打开一个纯净版的链路这个链路是怎么理解呢?这里特指是业务简单的链路,即满足用户最基本需求的链路。比如说我们有一个商城app,那么下单就是最关键的链路,我们只需要在app crash的时候,打开一个业务最简单的页面,让用户去操作即可,这样就避免二次可能产生的crash!


image.png


强制升级


如果某个用户在app的crash次数达到一定时,就直接采取强制升级的方案,让用户的app始终保持最新版本,避免由于老版本的影响导致这部分的用户流失。这个方案的实现可直接对接到app内的升级策略


脏数据清除


有一些crash可能是由于用户本地的脏数据引起而导致的,那么我们可以在crash的时候,把这部分数据清除,或者简单来说直接清除所有缓存,这种“重置”操作会一定程度上避免由于脏数据等特定crash的发生,比较适用于线上存在脏数据用户的情况。


安全气囊机制


可以看到,无论是哪一个方案,我们都需要依靠crash/anr检测的机制,才能够实现,没关系,相关的文章早已准备好黑科技!让Native Crash 与ANR无处发泄!,同时也配备了开源库Signal,运用Signal,我们可以实现很多crash后的安全措施,也希望大家运行起来demo,尝试一下各种脑洞大开的方案!


让业务能够持续稳定下去,降低由于异常导致的损失,这是笔者一开始想要实现的,当然,目前我们的库还在不断完善的过程中,也希望广大开发者能够加入进来,一起去探索一个新方向!


最后


当然,一个app好坏大部分责任在于产品的选择,赛道的选择!能否提供一个好的服务给用户,才是决定一款app好坏的标准!我们技术能做的,就是不断突破场景的限制,给产品提供好的工具啦!


本来决定要分享asm相关的,但是在洗澡的过程中发现其实很多对服务器端的容灾策略的思想也是可以在移动端上去进行的,在app的业务迭代过程中,一定会对稳定性造成很多挑战,在各大公司人员缩减的背景下更是如此,所以说,建立一套安全气囊装置,一定会是后面多个公司的探索方向!


作者:Pika
链接:https://juejin.cn/post/7120802538332372999
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

你真的敢落地Flutter桌面端吗?

如果你想用Flutter技术在桌面端落地,从技术上来讲,你必须解决这三大难题:1. 应用窗口化,提供窗口操作的能力;2. 实现多窗口;3. 对外设的支持。前言首先给个结论,Flutter在桌面端落地,完全是可行的;但生态远没有官方所说的那么完善,我甚至认为其达...
继续阅读 »

如果你想用Flutter技术在桌面端落地,从技术上来讲,你必须解决这三大难题:
1. 应用窗口化,提供窗口操作的能力;
2. 实现多窗口;
3. 对外设的支持。

前言

首先给个结论,Flutter在桌面端落地,完全是可行的;但生态远没有官方所说的那么完善,我甚至认为其达不到stable的标准
目前我们的桌面设备主要有Windows、Android系统,系统不同但UI一致,我们将在这两个平台上解决以上问题,并落地Flutter。

一、窗口化和窗口操作存在的问题

  1. 实现应用窗口化:即应用是窗口化展示的,同时可拖拽、可以点击应用外的地方
    Flutter Windows本身是窗口化的;
    而Android默认是全屏应用,需要让普通应用支持窗口化;若是小工具性质的应用,还需要支持可拖拽、可点击应用外的地方,这些在Flutter上都是需要我们在原生实现的。
  2. 实现应用窗口化后,一般开发过程中,肯定会需要以下对窗口的操作:
    • 应用窗体圆形、阴影效果;
    • 配置应用初始的显示位置;(很多小工具可能不是居中展示)
    • 从窗口变为全屏、从全屏变为窗口;
    • ......

二、支持多窗口

目前Flutter是明确不支持多窗口的。官方好像对多窗口不太感兴趣,一直没有把优先级提上来,还是停留在p4级别,具体见issue
但是作为桌面应用,多窗口的需求是非常普遍的,因此这个技术壁垒是必须打破的。

三、窗口化实现方案

1. Windows端

Windows端Flutter默认支持窗口化,交互方式基本符合习惯,因此无需再做开发。

2. Android端

  • Android普通应用实现窗口化,是把整个应用展示成窗口的效果,但是点击外部窗口外的地方其实是不响应。 同一时间只能显示一个应用进程,这是安卓的机制,也保证了其安全性。要实现窗口化,需要把应用Theme设置成Dialog的样式;同时设置窗口全屏,但是背景色为透明,设置点击外部Dialog不消失,即可实现应用的窗口化展示。

    1. 设置主题

      <style name="Theme.DialogApp" parent="Theme.AppCompat.Light.Dialog"> 
      <item name="android:windowBackground">@drawable/launch_application</item>
      <item name="android:windowIsTranslucent">true</item>
      <item name="android:windowContentOverlay">@null</item>
      <!-- 不显示遮罩层 -->
      <item name="android:backgroundDimEnabled">false</item>
      <item name="windowActionBar">false</item>
      <item name="windowNoTitle">true</item>
      </style>
      <activity
      android:name=".MainActivity"
      android:exported="true"
      android:hardwareAccelerated="true"
      android:launchMode="singleTop"
      android:theme="@style/Theme.DialogApp"
      android:windowSoftInputMode="adjustResize"> <meta-data
      android:name="io.flutter.embedding.android.NormalTheme"
      android:resource="@style/Theme.DialogApp" />
      <intent-filter>
      <action android:name="android.intent.action.MAIN" />
      <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
      </activity>
    2. 设置窗口全屏,但是背景色为透明,点击外部Dialog不消失

      class MainActivity : FlutterActivity() {
      // 设置窗口背景透明
      override fun getTransparencyMode(): TransparencyMode {
      return TransparencyMode.transparent
      }
      override fun onResume() {
      super.onResume()
      // 点击外部,dialog不消失
      setFinishOnTouchOutside(false)
      // 设置窗口全屏
      var lp = window.attributes
      lp.width = -1
      lp.height = -1
      window.attributes = lp
      }
      }
    3. 到这里原生提供给Flutte一个全屏的透明窗体,那么Flutter的视图想长成啥样都可以

  • 若是小工具之类的,需要实现应用可拖拽,可点击应用区域外,这在android的实现相对复杂。我们利用原生的窗口管理,弹出一个悬浮框,然后通过entry-point 找到Flutter层的UI。这其实就是我们实现多窗口的思路,这里就不单纯讲解,跟着后面一起讲了。

窗口化操作

实现窗口化后,需要做很多相关的操作,我们分两个系统讲。

1. Windows端

  • 应用窗体圆形、阴影效果:通过window_manager插件,让应用背景色透明;然后我们在MaterialApp外面套一层Container可以设置圆角和阴影,再在外面加一次Container,加入padding以展示内层容器的阴影;
  • 小工具配置初始位置:通过window_manager插件的setPosition可以设置位置;
  • 从窗口变为全屏、从全屏变为窗口:通过window_manager插件可以实现全屏和退出全屏,在切换的过程中页面会闪烁,解决思路是:把透明度设置为0 → 全屏 → 透明度恢复为1。设置透明度的方法也由window_manager插件提供。

2. Android端

对于普通应用,我们上面实现窗口化后,原生就已经为Flutter提供了一个透明的全屏窗口,因此任何窗体的操作都是Flutter层去实现的,没啥技术难度。

  • 应用窗体圆形、阴影效果:上面我们实现应用窗口后,其实整个应用窗体的背景色就是透明的了,因此我们比Windows少做了背景色透明这一步,然后后面的Container都是通用的,代码达到多平台复用;
  • 小工具配置初始位置:直接通过Stack和Positioned来配置就行了。但这种场景一般使用悬浮弹框做,设置定位见后面多窗口;
  • 从窗口变为全屏、从全屏变为窗口:Android依然很简单,只需要在全屏的时候把整个Flutter窗口的padding去除,恢复的时候加上就可以了。

多窗口的实现

首先明确一个观点,Flutter应用是基于Flutter engine,由原生提供的一个Surface画布,在这个画布上面用Skia 2绘制Flutter Widget。
也就是说本身这个应用就是一个窗口,它绝对没有能力为自己再创建一个窗口。 所以多窗口的实现,需要依赖于原生的窗口管理。下面是Android端的实现原理图,这个原理适用于任何平台。 

  • 原生新建一个Flutter engine,通过dart执行器DartExecutor执行方法executeDartEntrypoint,根据传入的字符串找到对应的方法入口点Entrypoint,从而拿到Flutter widget;
  • Flutter在方法上声明@pragma('vm:entry-point') 后,此方法即便在Flutter项目没有被调用到,也能编译进去,因此原生新的engine就能找到这个切入点,拿到方法返回的widget;

这是非常典型的Flutter玩法,诸如混合开发都是如此。带来的影响是存在多引擎(engine),增加一些内存,但是这个不可避免,除非你定制Flutter引擎. 目前pub上支持多窗口的库也都是这个原理,但是库的质量其实不高,大家还是自己写吧。

实现步骤

  1. Plugin与原生通信,由于操作都是异步的,所以务必使用双向通信机制BasicMessageChannel,而且需要两个通道:主应用与子窗口通道
  2. 定义接口协议,一般至少需提供以下能力
// 主应用打开子窗口
void open(String entryPoint, Size size, GravityConfig? gravityConfig,
bool draggable);

// 主应用关闭子窗口
void close();

// 主应用设置大小
void resize(int width, int height);

// 主应用设置位置
void setPosition(int x, int y);

// 子窗口启动app,需要支持后台唤起以及命令行启动
void launchApp();

// 子窗口自行关闭
void closeByWindows();

// 子窗口设置大小
void resizeByWindows(int width, int height);

// 子窗口设置位置
void setPositionByWindows(int x, int y);
  1. 各端实现,下面贴下Android端的关键代码
  • 新建Flutter engine,找到Dart中的方法,此时engine就拿到了Flutter的widget实例;

    engine = FlutterEngine(application)
    val entry = intent.getStringExtra("entryPoint") ?: "multiWindow"
    val entryPoint = DartExecutor.DartEntrypoint(findAppBundlePath(), entry)
    engine.dartExecutor.executeDartEntrypoint(entryPoint)
  • 新建窗口管理类,通过FlutterViewe吸附engin,然后渲染到悬浮框的view上

    ///......
    private var windowManager = service.getSystemService(Service.WINDOW_SERVICE) as WindowManager
    ///......
    windowManager.addView(rootView, layoutParams)
    ///......///......
    flutterView = FlutterView(inflater.context, FlutterSurfaceView(inflater.context, true))
    flutterView.attachToFlutterEngine(engine)
    ///......
    engine.lifecycleChannel.appIsResumed()
    ///......
    rootView.findViewById<LinearLayout>(R.id.floating_window)
    .addView(
    flutterView,
    ViewGroup.LayoutParams(
    ViewGroup.LayoutParams.MATCH_PARENT,
    ViewGroup.LayoutParams.MATCH_PARENT
    )
    )
  1. 实现悬浮框后,Android平台上的桌面小工具,也就顺利成章的实现了,只是在小工具这个项目的MainAcitivy上,就不需要去加载FlutterActivity了,直接启动悬浮框即可。

外设支持

usb设备在Flutter上,支持也是非常若的。具体可见我上一篇文章:Flutter桌面端实践之识别外接媒体设备

写在最后

以上是我在桌面端预研Flutter的一些经验和思路分享,如果你想在桌面端落地Flutter,我想这边文章对你是很有帮助的。
以上问题,我们遇到了,也解决了。但转念一想这么多基础的操作Flutter都不支持,这真的可以称得上Stable版本了吗?
Flutter桌面端的生态,急需我们共同建设,文中多次提起的window_manager插件就是国内出色的组织:LeanFlutter 提供的,期待Flutter桌面端越来越好!


作者:Karl_wei
链接:https://juejin.cn/post/7120944715419090957
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android性能优化之APK瘦身详解(瘦身73%)

公司项目在不断的改版迭代中,代码在不断的累加,终于apk包不负重负了,已经到了八十多M了。可能要换种方式表达,到目前为止没有正真的往外推过,一直在内部执行7天讨论需求,5天代码实现的阶段。你在写上个版本的内容,好了,下个版本的更新内容已经定稿了。基于这种快速开...
继续阅读 »

公司项目在不断的改版迭代中,代码在不断的累加,终于apk包不负重负了,已经到了八十多M了。可能要换种方式表达,到目前为止没有正真的往外推过,一直在内部执行7天讨论需求,5天代码实现的阶段。你在写上个版本的内容,好了,下个版本的更新内容已经定稿了。基于这种快速开发的现状,我们app优化前已经有87.1M了,包大了,运营说这样转化不高,只能好好搞一下咯。优化过后包大小为23.1M(优化了73%,不要说我标题党)。好了好了,我要阐述我的apk超级无敌魔鬼瘦身之心得了。


文章主要内容从理论出发,再做实际操作。分为下面几个方面:



  1. 结构分析

  2. 具体实操

  3. 总结

  4. 参考资料



1. 结构分析


首先上传一张瘦身前通过Analyze app分析出来的图片(打开方式:Android Studio下 ——> Build——> Analyze app):


这里写图片描述


APK包结构如下:



  1. lib/:包含特定于处理器软件层的编译代码。该目录包含了每种平台的子目录,像armeabi,armeabi-v7a, arm64-v8a,x86,x86_64,和mips。大多数情况下我们可以只用一种armeabi-v7a,后面会讲到原因。

  2. assets/:包含应用可以使用AssetManager对象检索的应用资源。

  3. res/:包含未编译到的资源 resources.arsc,主要有图片资源文件。

  4. META-INF/:包含CERT.SF和 CERT.RSA签名文件以及MANIFEST.MF 清单文件。

  5. resources.arsc:包含已编译的资源。该文件包含res/values/ 文件夹所有配置中的XML内容。打包工具提取此XML内容,将其编译为二进制格式,并将内容归档。此内容包括语言字符串和样式,以及直接包含在resources.arsc文件中的内容路径 ,例如布局文件和图像。

  6. classes.dex:包含以Dalvik / ART虚拟机可理解的DEX文件格式编译的类。

  7. AndroidManifest.xml:包含核心Android清单文件。该文件列出应用程序的名称,版本,访问权限和引用的库文件。该文件使用Android的二进制XML格式。


通过分析图可以知道,目前app主要是so文件占比比较大,占了31.7M,占了整个应用是38.2%。其次是assets目录,整个目录占了32M,第三就是资源文件res目录了。所以接下来我们处理步骤就是按这个顺序来处理。(简单说下图中的Raw File Size(磁盘解压后的大小)和DownLoad Size(从应用商店下载的大小),如果想了解更多关于Analyaer分析的知识,可以参考这篇文章使用APK Analyzer分析你的APK),分析了包结构组成之后,我们可以开始瘦身操作了。


2.具体实操


1. 对lib目录下的文件进行瘦身处理


1. 修改lib配置:


参考资料
so文件的优化:通常我们在使用NDK开发的时候,我们经常会有如下这么一段代码:


ndk {
//设置支持的so库架构
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64", "armeabi"
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xNDtH3zt-1571353784450)(upload-images.jianshu.io/upload_imag…)]


最后我的修改代码如下:


ndk 	{
//设置支持的so库架构
abiFilters "armeabi-v7a"
}

接下来说明这么做的依据:
看上面图分析,armeabi-v7主要不支持ARMv5(1998年诞生)和ARMv6(2001年诞生).目前这两款处理器的手机设备基本不在我公司的适配范围(市场占比太少)。
而许多基于 x86 的设备也可运行 armeabi-v7a 和 armeabi NDK 二进制文件。对于这些设备,主要 ABI 将是 x86,辅助 ABI 是 armeabi-v7a。
最后总结一点:如果适配版本高于4.1版本,可以直接像我上面这样写,当然,如果armeabi-v7a不是设备主要ABI,那么会在性能上造成一定的影响。
参考文章:安卓app打包的时候还需要兼容armeabi么?


好了,我们再打一次包试试。
这里写图片描述


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xv3lhgYo-1571353784451)(upload-images.jianshu.io/upload_imag…)]
确实有点震惊,一下子包小了这么多,从87.1M到51.9M,容我好好算算少了多少M.赶快让测试帮忙测一下。基于之前的理论知识,心里还是有点底。果然,测试效果和之前是一样的。心里的石头先落下罗。


2. 重新编译so文件,用更小的库代替


相信很多开发者都有这种苦恼,很多第三方我们导入进来只用到其中很小一部分功能,大部分功能都是我们用不上的。这时候我们找到源代码,将我们需要的那部分代码提取出来,重新编译成新的so文件,再导入到我们项目中。当然,如果之前没有编译过so文件,这部分建议做最后的优化去处理。不然你会遇到很多问题。上一波处理后的效果图:


这里写图片描述
这里说下,因为项目中有使用到ffmpeg库,之前导入的第三方的放在assets文件夹下,重写编写后的so库文件放在lib文件夹下,所以lib文件夹反而大了。从51.9M到35.6M,效果还是蛮不错的。


对了,别问我为什么assets文件夹下为什么还有12.6M资源,因为很多.mp3都是第三方的人脸识别必备配置文件,我也很无奈。


这里写图片描述


2. 优化res,assets文件大小


1. 手动lint检查,手动删除无用资源


在Android Studio中打开“Analyze” 然后选择"Inspect Code...",范围选择整个项目,然后点击"OK"。配置如下:


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aczX7vG1-1571353784454)(upload-images.jianshu.io/upload_imag…)]


2. 使用tinypng等图片压缩工具对图片进行压缩。


打开网址,将大图片导入到tinypng,替换之前的图片资源。


3. 大部分图片使用Webp格式代替。


可以给UI提要求,让他们将图片资源设置为Webp格式,这样的话图片资源会小很多。如果想了解更多关于webp,请点击这里webp,当然,如果对图片颜色通道要求不高,可以考虑转jpg,最好用webp,因为效果更佳。


4. 尽量不要在项目中使用帧动画


一个帧动画几十张图片,再怎么压缩都还是占很大内存比重的。所以建议是让UI去搞,这里可以参考使用lottie-android,如果项目中动画效果多的话效果更加明显。当然这就要辛苦我们UI设计师大大了。


5. 使用gradle开启shrinkResources


移除无用资源文件,下面是我的配置:


 buildTypes {
release {
// 不显示Log
buildConfigField "boolean", "LOG_DEBUG", "false"
//混淆
minifyEnabled true
// 移除无用的resource文件
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}

通过上述步骤操作,apk效果如下:


这里写图片描述


又优化了将近5M,别问我为什么还有7.5M,里面大量的gif和webp格式的动图,都是UI丢给我的,一个2.7M.后面再慢慢和他细究这个问题。后面要做的两部分,一部分是将资源文件下的所有gif图放后台下载处理,第二个是和UI讨论下如何减小webp 动图的大小(我看其他平台只有100K的样子,给我的就2.7M?)。


3. 减少chasses.dex大小


classes.dex中包含了所有的java代码,当你打包时,gradle会将所有模板力的.class文件转换成classes.dex文件,当然,如果方法数超过64K,将要新增其他文件进行存储。可以通过multidexing分多个文件,比如我这里的chasses2.dex。换句话说,就是减少代码量。我们可以通过以下方法来实现:



  1. 尽量减少第三方库的引用,这个在上面我们已经做过优化了。

  2. 避免使用枚举,这里特别去网上查了一下,具体可以参考下这篇文章Android 中的 Enum 到底占多少内存?该如何用?,得出的结论是,可能几十个枚举的内存占有量才相当一张图片这样子,优化效果也不会特别明显。当然,如果你是个追求极致的人,我不反对你用静态常量替代枚举。

  3. 如果你的dex文件太大,检查是否引入了重复功能的第三方库(图片加载库,glide,picasso,fresco,image_loader,如果不是你一个人单独开发完成的很容易出现这种情况),尽量做到一个功能点一个库解决。


关于classes.dex文件大小分析可以参考这篇译文使用 APK Analyzer 分析你的 APK


4. 其他



  1. 删除无用的语7zip代替

  2. 删除翻译资源,只保留中英文

  3. 尝试将andorid support库彻底踢出你的项目。

  4. 尝试使用动态加载so库文件,插件化开发。

  5. 将大资源文件放到服务端,启动后自动下载使用。


3. 总结


好了,说道这里基本上就结束了,apk包从87.1M减小到了23.1M(优化了73%,不要说我标题党)已经差不多了,关于第四部其他部分的优化我是没有进行再操作的。因为公司运营觉得二三十M的包比较真实,太小了就太假了。所以我暂时就不进行优化了。如果再上面提到的部分通过所有将所有非启动页面首页之外的所有资源,so库放服务端,理论上apk包大小能在10M以内这样子。当然我们有做到就不多加评价了。最后,如果对插件化开发感兴趣的话可以参考下这篇文章Android全面插件化方案-RePlugin踩坑。最后,如果你在Android上有什么疑问,可以添加我的同名微信公众号「aserbaocool」和我一块交流。


4. 参考资料:


文章主要参考文章如下,文章有少部分文字参考了下面文章中的语句。如果有侵犯到作者权益,请和我联系,查实后马上删除。



  1. Android APK 瘦身 - JOOX Music项目实战

  2. APK 瘦身记,如何实现高达 53% 的压缩效果

  3. 使用APK Analyzer分析你的APK

  4. 安卓app打包的时候还需要兼容armeabi么?

  5. 百度百科webp

  6. Android 中的 Enum 到底占多少内存?该如何用?

作者:aserbao
链接:https://juejin.cn/post/7119445311868633096
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android动态更换应用图标

一、背景 近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还...
继续阅读 »

一、背景


近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还有梦幻紫、幻想星空等抽象派新造型,给了微博用户多种选择的自由。


不过需要注意的是,这一功能并不是面对所有人开放的,只有微博年费会员才能享受。此外,iOS 10.3及以上和Android 10及以上系统版本支持该功能,但是iPad与一加8Pro手机无法使用该功能。因部分手机存在系统差异,会导致该功能不可用,微博方面后续还会对该功能进行进一步优化。


image.png


二、技术实现


其实,说到底,上述功能用到的是动态更换桌面图标的技术。如果说多年以前,实现图标的切换还是一种时髦的技术,那么,我们可以直接使用PackageManager就可以实现动态更换桌面图标。


实现的细节是,在Manifest文件中使用标签准备多个Activity入口,没个activity都指向入口Activity,并且为每个拥有标签的activity设置单独的icon和应用名,最后调用SystemService 服务kill掉launcher,并执行launcher的重启操作。


首先,我们在AndroidManifest.xml文件中添加如下代码:


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

<!-- 权限-->
<uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES"/>

<application
android:allowBackup="true"
android:icon="@mipmap/wb_default_logo"
android:label="@string/app_name"
android:roundIcon="@mipmap/wb_default_logo"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidDemo">

...//省略其他代码

<!-- 默认微博-->
<activity-alias
android:name="com.xzh.demo.default"
android:targetActivity=".MainActivity"
android:label="@string/app_name"
android:enabled="false"
android:icon="@mipmap/wb_default_logo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<!-- 3D微博-->
<activity-alias
android:name=".threedweibo"
android:targetActivity=".MainActivity"
android:label="@string/wb_3d"
android:enabled="false"
android:icon="@mipmap/wb_3dweibo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

... //省略其他

</application>
</manifest>

上面配置中涉及到的属性如下:



  • android:name:注册的组件名字,启动组件的名称。

  • android:enabled:是否启用这个组件,也就是是否显示这个入口。

  • android:icon:图标

  • android:label:名称

  • android:targetActivity:默认的activity没有这个属性,指定目标activity,与默认的activity中的name属性是一样的,需要有相应的java类文件。


接着,我们在MainActivity触发Logo图标更换逻辑,代码如下:


class MainActivity : AppCompatActivity() {
var list: List<LogoBean> = ArrayList()
var recyclerView: RecyclerView? = null
var adapter: LogoAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initView()
initData()
initRecycle()
}
private fun initView() {
recyclerView = findViewById(R.id.recycle_view)
}
private fun initData() {
list = Arrays.asList(
LogoBean(R.mipmap.wb_default_logo, "默认图标", true),
LogoBean(R.mipmap.wb_3dweibo, "3D微博", false),
LogoBean(R.mipmap.wb_cheese_sweetheart, "奶酪甜心", false),
LogoBean(R.mipmap.wb_chocolate_sweetheart, "巧克力", false),
LogoBean(R.mipmap.wb_clear_colorful, "清透七彩", false),
LogoBean(R.mipmap.wb_colorful_sunset, "多彩日落", false),
LogoBean(R.mipmap.wb_colorful_weibo, "炫彩微博", false),
LogoBean(R.mipmap.wb_cool_pool, "清凉泳池", false),
LogoBean(R.mipmap.wb_fantasy_purple, "梦幻紫", false),
LogoBean(R.mipmap.wb_fantasy_starry_sky, "幻想星空", false),
LogoBean(R.mipmap.wb_hot_weibo, "热感微博", false),
)
}
private fun initRecycle() {
adapter =LogoAdapter(this,list);
val layoutManager = GridLayoutManager(this, 3)
recyclerView?.layoutManager = layoutManager
recyclerView?.adapter = adapter
adapter?.setOnItemClickListener(object : OnItemClickListener {
override fun onItemClick(view: View?, position: Int) {
if(position==1){
changeLogo("com.xzh.demo.threedweibo")
}else if (position==2){
changeLogo("com.xzh.demo.cheese")
}else if (position==3){
changeLogo("com.xzh.demo.chocolate")
}else {
changeLogo("com.xzh.demo.default")
}
}
})
}

fun changeLogo(name: String) {
val pm = packageManager
pm.setComponentEnabledSetting(
componentName,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
)
pm.setComponentEnabledSetting(
ComponentName(this, name),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
)
reStartApp(pm)
}
fun reStartApp(pm: PackageManager) {
val am = getSystemService(ACTIVITY_SERVICE) as ActivityManager
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_HOME)
intent.addCategory(Intent.CATEGORY_DEFAULT)
val resolveInfos = pm.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
if (resolveInfo.activityInfo != null) {
am.killBackgroundProcesses(resolveInfo.activityInfo.packageName)
}
}
}
}

注意上面的changeLogo()方法中的字符串需要和AndroidManifest.xml文件中的<activity-alias>的name相对应。运行上面的代码,然后点击应用中的某个图标,就可以更换应用的桌面图标,如下图所示。


image.png


不过,测试的时候也遇到一些适配问题:



  • 小米9:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标消失。

  • magic 4:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标切换到默认图标,但点击之后未能打开APP。

作者:xiangzhihong
链接:https://juejin.cn/post/7115413271946985480
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

堪比坐牢!深圳一公司给每个工位都装监控,只为防止泄密?

近日,据@白鹿视频报道,网传深圳一家公司的办公室内,每个工位上都“一对一”安装了监控摄像头。 从爆料的图片可以看出,摄像头直对电脑屏幕,员工的操作可以被清晰拍到。            &n...
继续阅读 »

近日,据@白鹿视频报道,网传深圳一家公司的办公室内,每个工位上都“一对一”安装了监控摄像头。 


从爆料的图片可以看出,摄像头直对电脑屏幕,员工的操作可以被清晰拍到。



                             监控系游戏研发公司安装,防止员工泄密

随后有网友发现,图片中门上贴着的logo是一家科技公司,但当极目新闻记者联系到这家公司的负责人后,该负责人表示公司在今年4月就搬走了,摄像头并不是他们装的。门上的logo是搬走之后留下的装修,现在不知道租给了谁。


另据负责安装监控的师傅称,他6月份曾安装过一次,上周又安装了第二次,但不清楚具体是干什么的。

那监控到底是什么人安装的呢?记者从该公司所在的写字楼租赁处了解到,装监控的是一家5月份搬来的游戏研发公司。这家公司刚装修时,租赁处的工作人员就发现装了很多摄像头,还曾问过是不是为了防止员工摸鱼。当时一位工作人员解释称,公司是做游戏研发的,老板需要关注到游戏研发的每一个细节,在游戏还没上线的情况下,万一出现泄密会造成很大损失,公司也没法和投资人交代。

对于这一说法,网友们显然不能接受,认为就是换个说法在监控员工:

  • “这哪是打工,跟坐大牢一样!”

  • “高情商:防泄密;低情商:监控员工摸鱼”

  • “侵犯隐私权,你要是为了防止游戏泄密大可在电脑上监控,你搞个摄像头对准人什么意思啊”

  • “防泄密有很多技术手段可以用。通过安监控来防泄密,看来这家公司技术也不咋样!”

根据网传的最新消息,涉事公司还在筹建和装修阶段,尚未注册、没有具体名称、还未开始招聘员工和开展业务。据筹建方一位合伙人表示:“在办公公共区域安装摄像头的初衷是防止游戏在未发行之前泄露,监控内容也不会用于其他用途。如招聘员工,将事先征得员工同意。安装监控摄像头也花了2万余元,但因为现在引起部分网友误解,我们已经将它拆除了。”


监控员工手段层出不穷

在每个工位上都安装监控的做法让人大开眼界,但近年来,网上曝出各公司监控员工的手段可谓是层出不穷。

去年11月,国美一则对员工的通报火上热搜。通报显示,国美通过排查员工在工作时间使用公司公共网络资源的情况,通报了11名员工。让人吃惊的是,通报结果详细到在哪些软件分别使用了多少G流量。

今年4月,武汉一位网友爆料,称公司因为近期效益不好,领导要求员工下班前将手机电量消耗截图私发检查,查看员工各个APP的使用情况,防止员工在上班时摸鱼玩手机。对此,网友纷纷表示很无语,比装监控还可怕。

今年5月,在北京不少公司实行居家办公政策时,有网友称教育机构尚德要求员工连夜在电脑上安装监控软件。摄像头每5分钟抓拍一次人脸,如果几次抓拍不到,就要扣除全部绩效,领导和HR也跟着扣钱,甚至不够89次算旷工。对于这一规定,有员工表示“大家都不敢去上厕所”。

除此之外,还有公司搞出智能座椅,只要员工离开就开始计时;员工上厕所要手机扫码,门框上装着计时器,只要超时就通知领导……

虽然各大企业为了监控员工手段尽出,但正如职场上流传的一句话:“公司开始突然严抓纪律和考勤,是企业衰败的标志”。所以,与其想着怎么监控员工,不如想办法调动员工的工作积极性。

来源链接:

收起阅读 »

一个老程序员的30年生涯回顾(译文)

1、1967年,我13岁时开始学习编程。1988年,我正式进入了软件行业,通过编程养活自己。那一年,我34岁。2、1989年,我加入微软公司,那是微软为程序员提供单人办公室的最后一年。我们编程时,几乎没有干扰,这真是太好了。当时,微软的观念是必须为程序员创造不...
继续阅读 »

1、


1967年,我13岁时开始学习编程。

1988年,我正式进入了软件行业,通过编程养活自己。那一年,我34岁。

2、


1989年,我加入微软公司,那是微软为程序员提供单人办公室的最后一年。我们编程时,几乎没有干扰,这真是太好了。当时,微软的观念是必须为程序员创造不受打扰的环境,让他们全身心地投入工作。

3、


1990年5月,Windows 3.0 发布,公司出现了真正的变化。

突然之间,我与一个吸烟的同事共用一个办公室,他整天在电话里大声聊天。更糟糕的是,我们开始有更多的会议。

4、


接下来的20年,情况越变越糟。程序员像农奴一样被使用,许多人饱受压力、精疲力尽,每周工作70个小时以上。但是实际上,其中真正用来完成工作的时间只有4-6个小时,其余时间都为通过质量检查系统苦苦挣扎,设法应付各种质量措施。

5、


到了2009年,一切都变得混乱了。程序员对代码质量的热爱,完全被复选框式的机械处理取代了。在2008年末,我的主管要求我,代码都必须有单元测试,以便在系统中为该项目勾选"具有单元测试"的那个框。不久,他又要求我尝试"测试驱动的开发"(TDD)的新编程模式。

最后,当他们要求我做结对编程时,第二天我就因为愤怒而辞职了。

6、


离开微软后,我去了西雅图市中心的 Real Networks 公司工作。在西雅图,交通堵塞是一个大问题,我一般在早上高峰时间之后的9:30去上班,这样只要开车30分钟,就能到公司,还算不错。

7、


不久,我所在的团队开始尝试敏捷开发,每天早上8:30举行一次"站立会议"。这正好赶上早高峰,30分钟的通勤时间变成了90分钟,我必须在早上7:00出门才行。我几乎没有办法准时到达,并且感到非常疲倦。我询问是否可以稍微推迟会议。不行,你难道不知道站立会议必须在早晨举行吗?

为此,我只能(无偿地)多花了额外的时间开车去上班。

8、


这种会议真是很荒谬,每个程序员报告自己正在做的事情。大部分时候,我们做的事情跟昨天相同,偶尔会做一些新的事情,但没有什么特别可说的。会议上,产品经理会表现出生机勃勃、欢快愉悦的情绪,听起来很投入,而实际上我知道他们上班时很多时间都在脸书上玩游戏。

9、


许多次,我听到"故事"(Story)这个词。我问,"故事"是什么意思?回答是用户场景或者使用案例的新名称。随着我对敏捷开发的了解越多,遇到的重命名和名词重定义就越多。我看不出来这能对工作带来多少的新价值,唯一带来的就是更多的会议。

我建议不要使用"故事"这个名词,结果被冷淡地告知,"故事"是敏捷开发的一部分,我们将紧跟这种新的开发方法。

10、


我的原计划是,2019年65岁时退休,然后搬到东南亚国家享受退休生活。但是,经历过了沉闷的站立会议、白板上的迭代看板、一系列高压力的工作、对"故事"的不停谈论,我越来越对这个工作感到恶心。

2010年11月15日,56岁时,我退休了。

11、


我在越南买了一栋房子(上图),然后收拾行装,离开了美国。我非常喜欢这栋漂亮的新房子,准备在那边弹吉他,阅读物理书籍,体验截然不同的文化,放松身心。

12、


在越南过了一段日子以后,生活变得很闲,我只好把时间用来学越南语,否则就太无聊了。

13、


一位朋友建议我可以试试 iPhone 和 iPad 开发,软件工具是免费的。我怀念编程,就买了一台 MacBook,学习了 iOS、Objective-C 和 Xcode,很快就写出了一个可以出售的 App。我又回到了这个行业。

14、


2011年到2016年,我一开始为自己写 iOS 和 MacOS 应用程序,然后出售。这样也不错,但是我想挣更多的钱,就开始通过自由职业网站的中介,接一些客户的活。

15、


2017年,我获得了一家加利福尼亚公司的远程工作,为他们做服务器端开发。我学习了 C#、Entity 框架、ASP. NET。当推荐我的人离职了,我就接管了服务器端和数据库开发。这样已经持续了30个月。这是一段很棒的经历,让我掌握了一些最新技能,我喜欢服务器端和数据库编程。

这些时间我一直是一个人工作,但也是团队的一员。整个开发部门都是远程的,浏览器客户端开发人员在悉尼,我在越南。我们通过 RESTful API 协作,彼此都是独立工作。

16、


回顾我的30年程序员生涯,软件行业发生了翻天覆地的变化。

现在的软件业有更多时尚的行话和术语,比如用户故事、技术债务、敏捷、重构、迭代、里程碑等等。在我看来,所谓迭代,就是说这段时间你会过度劳累,没有其他含义。

奇怪的是,他们用各种办法监督程序员,但是招聘的时候,职位要求依然写着,需要具有独立工作精神、高度主动性的人。这真是讽刺。

17、


现在的软件业还流行开放办公室,这意味着完全不可能集中精力。你的工作被持续不断地打断,没法关门保持沉默和集中注意力。如果你戴着耳机,就意味着你的团队合作精神不够。

18、


最后,测试已经变味了。以前,我在微软公司,我们没有那么认真对待测试。微软经常开玩笑说,任何人都不应该使用偶数版本的软件,因为它是测试版,适合那些愿意向我们报告错误的客户。比如,请勿使用2.0版,因为2.1版将修复客户报告的所有2.0版的错误,至少是比较严重的错误。

现在的软件业提倡测试驱动开发这种荒谬方法。我在许多地方都读到,在软件开发中,没有什么比单元测试更重要了,甚至比交付成果的本身还要重要。单元测试是设计,是定义API的地方。测试覆盖率不到100%,就是存在欠缺,100%覆盖率是程序员的荣誉, 开发人员应该负责测试他们的产品。我们不再需要黑匣子测试流程,也不需要测试工程师。

我认为,这些态度充满了狂热主义。每个人都有盲点,总是会存在忽略编写测试的案例与忽略编写代码的案例。

19、


我喜欢编程,喜欢解决问题和开发功能,从小开始直到现在都是如此。

以前,我选择服从那些流行的做法,但是现在不会了。我不会在开放式办公室工作,不会持续一个星期听所谓的专业术语,不会将各种新词用来描述旧事物,不会结对编程,不会参加频繁的会议,不会在意对团队协作精神的要求,也不会嘲笑那些独自工作的人。

20、


我喜欢服务器端开发,未来希望还可以做这方面的工作。同时,我正在转向技术写作,学习远程工作所需的新技能。

我喜欢现在这种一点不疯狂的环境。

原文网址:hackernoon.com

作者:Chris Fox,翻译:阮一峰

收起阅读 »

程序员的酒后真言

美国最大的论坛 Reddit,最近有一个热帖。一个程序员说自己喝醉了,软件工程师已经当了10年,心里有好多话想说,"我可能会后悔今天说了这些话。"他洋洋洒洒写了一大堆,获得9700多个赞。内容很有意思,值得一读,下面是节选。(1)职业发展的最好方法是换公司。(...
继续阅读 »

美国最大的论坛 Reddit,最近有一个热帖

一个程序员说自己喝醉了,软件工程师已经当了10年,心里有好多话想说,"我可能会后悔今天说了这些话。"


他洋洋洒洒写了一大堆,获得9700多个赞。内容很有意思,值得一读,下面是节选。


(1)职业发展的最好方法是换公司。

(2)技术栈不重要。技术领域有大约 10-20 条核心原则,重要的是这些原则,技术栈只是落实它们的方法。你如果不熟悉某个技术栈,不需要过度担心。

(3)工作和人际关系是两回事。有一些公司,我交到了好朋友,但是工作得并不开心;另一些公司,我没有与任何同事建立友谊,但是工作得很开心。

(4)我总是对经理实话实说。怕什么?他开除我?我会在两周内找到一份新工作。

(5)如果一家公司的工程师超过 100 人,它的期权可能在未来十年内变得很有价值。对于工程师人数很少的公司,期权一般都是毫无价值。

(6)好的代码是初级工程师可以理解的代码。伟大的代码可以被第一年的 CS 专业的新生理解。

(7)作为一名工程师,最被低估的技能是记录。说真的,如果有人可以教我怎么写文档,我会付钱,也许是 1000 美元。

(8)网上的口水战,几乎都无关紧要,别去参与。

(9)如果我发现自己是公司里面最厉害的工程师,那就该离开了。

(10)我们应该雇佣更多的实习生,他们很棒。那些精力充沛的小家伙用他们的想法乱搞。如果他们公开质疑或批评某事,那就更好了。我喜欢实习生。

(11)技术栈很重要。如果你使用 Python 或 C++ 语言,就会忍不住想做一些非常不同的事情。因为某些工具确实擅长某些工作。

(12)如果你不确定自己想做什么东西,请使用 Java。这是一种糟糕的编程语言,但几乎无所不能。

(13)对于初学者来说,最赚钱的编程语言是 SQL,干翻所有其他语言。你只了解 SQL 而不会做其他事情,照样赚钱。人力资源专家的年薪?也许5万美元。懂 SQL 的人力资源专家?9万美元。

(14)测试很重要,但 TDD (测试驱动的开发)几乎变成了一个邪教。

(15) 政府单位很轻松,但并不像人们说的那样好。对于职业生涯早期到中期的工程师,12 万美元的年薪 + 各种福利 + 养老金听起来不错,但是你将被禁锢在深奥的专用工具里面,离开政府单位以后,这些知识就没用了。我非常尊重政府工作人员,但说真的,这些地方的工程师,年龄中位数在 50 岁以上是有原因的。

(16)再倒一杯酒。

(17)大多数头衔都无关紧要,随便什么公司都可以有首席工程师。

(18)手腕和背部的健康问题可不是开玩笑的,好的设备值得花钱。

(19)当一个软件工程师,最好的事情是什么?你可以结识很多想法相同的人,大家互相交流,不一定有相同的兴趣,但是对方会用跟你相同的方式思考问题,这很酷。

(20)有些技术太流行,我不得不用它。我心里就会很讨厌这种技术,但会把它推荐给客户,比如我恨 Jenkins,但把它推荐给新客户,我不觉得做错了。

(21)成为一名优秀的工程师意味着了解最佳实践,成为高级工程师意味着知道何时打破最佳实践。

(22)发生事故时,如果周围的人试图将责任归咎于外部错误或底层服务中断,那么是时候离开这家公司,继续前进了。

(23)我遇到的最好的领导,同意我的一部分观点,同时耐心跟我解释,为什么不同意我的另一部分观点。我正在努力成为像他们一样的人。

(24)算法和数据结构确实重要,但不应该无限夸大,尤其是面试的时候。我没见过药剂师面试时,还要测试有机化学的细节。这个行业的面试过程有时候很糟糕。

(25)做自己喜欢的事情并不重要,不要让我做讨厌的事情更重要。

(26)越接近产品,就越接近推动收入增长。无论工作的技术性如何,只要它接近产品,我都感到越有价值。

(27)即使我平时用 Windows 工作,Linux 也很重要。为什么?因为服务器是 Linux 系统,你最终在 Linux 系统上工作。

(28)人死了以后,你想让代码成为你的遗产吗?如果是那样,就花很多时间在代码上面吧,因为那是你的遗产。但是,如果你像我一样,更看重与家人、朋友和生活中其他人相处的时光,而不是写的代码,那就别对它太在意。

(29)我挣的钱还不错,对此心存感激,但还是需要省钱。

(30)糟糕,我没酒了。

来源:http://www.ruanyifeng.com/blog/2021/06/drunk-post-of-a-programmer.html

收起阅读 »

Swift 中的热重载

前言    这一年是2040年,我们最新的 MacBook M30X 处理器可以感知到瞬间编译大型 Swift 项目,听起来很神奇,对吧?除此之外,编译代码库只是我们迭代周期的一部分。包括:    1...
继续阅读 »

前言

    这一年是2040年,我们最新的 MacBook M30X 处理器可以感知到瞬间编译大型 Swift 项目,听起来很神奇,对吧?除此之外,编译代码库只是我们迭代周期的一部分。包括:
    1、重新启动它(或将其部署到设备)
    2、导航到您在应用程序中的先前位置
    3、重新生成您需要的数据。
    如果您只需要做一次的话,听起来还不错。但是如果您和我一样,在特别的一天中,对代码库进行 200 - 500 次迭代,该怎么办呢?它增加了。
    有一种更好的方法,被其他平台所接受,并且可以在 Swift/iOS 生态系统中实现。我已经用了十多年了。
    从今天开始,您想每周节省多达 10 小时的工作时间吗?


热重载

    热重载是关于摆脱编译整个应用程序并尽可能避免部署/重新启动周期,同时允许您编辑正在运行的应用程序代码并且能立即看到更改。
    这种流程改进可以每天为您节省数小时的开发时间。我跟踪我的工作一个多月,对我来说,每天节省了 1-2 小时。
    坦白地说,如果每周节省10个小时的开发时间都不能说服您去尝试,那么我认为任何方法都不能说服你。


其他平台在做什么?

    如果您只使用 Apple 平台,您会惊讶地发现有好多平台几十年前已经采用了热重载。无论您是编写 Node 还是任何其他 JS 框架,都有一个使用热重载的设置。Go 也提供了热重载(本博客使用了该特性)
    另一个例子是谷歌的 Flutter 架构,从一开始就设计用于热重载。如果您与从事 Flutter 工作的工程师交谈,你会发现他们最喜欢 Flutter 开发者体验的一点就是能够实时编写他们的应用程序。当我为《纽约时报》写了一个拼字游戏时,我很喜欢它。
    微软最近推出了 Visual Studio 2022,并为 .NET 和 标准 C++ 应用程序提供热重载,在过去的十年中,微软在开发工具和经验方面一直在大杀四方,所以这并不令人惊讶。


苹果生态系统怎么样?

    早在 2014 年推出时,很多人都对 Swift Playgrounds 感到敬畏,因为它们允许我们快速迭代并查看代码的结果,但它们并不能很好地工作,因为它存在崩溃、挂起等问题。不能支持整个iPad环境。
    在它们发布后不久,我启动了一个名为 Objective-C Playgrounds 的开源项目,它比官方 Playgrounds 运行得更快、更可靠。我的想法是设计一个架构/工作流程,利用我已经使用了几年的 DyCI 代码注入工具,该工具已经由 Paul 制作。
    自从 Swift Playgrounds 存在以来,已经过去了八年,而且它们变得更好了,但它们可靠吗?人们是否在使用它们来推动开发?

    SwiftUI 出现了,它是一项了不起的技术(尽管仍然存在错误),它引入了与 Playgrounds 非常相似的 Swift Previews 的想法,它们有什么好处吗?
    类似的故事,当它工作的时候是很好的,但是在更大的项目中,它的工作是不可靠的,而且往往中断的次数比它们工作的次数多。如果你有任何错误,他们不会为你提供调试代码的能力,因此,采用的情况有限。


我们需要等待 Apple 吗?

    如果你关注我一段时间,你就已经知道答案了,绝对不要。毕竟,我的职业生涯是构建普通 Apple 解决方案无法解决的问题:从像 Sourcery 这样的语言扩展、像 Sourcery Pro 这样的 Xcode 改进,再到 LifetimeTracker 以及许多其他开源工具。
    我们可以利用我最初在 2014 Playgrounds 中使用的相同方法。我已经使用它十多年了,并且在数十个 Swift 项目中使用它并取得了巨大的成功!
    许多年前,我从使用 DyCI 切换到 InjectionForXcode,通过利用 LLVM 互操作而不是任何 swizzling ,它的效果更好。它是一个完全免费的开源工具,您可以在菜单栏中运行,它是由多产的工程师 John Holdsworth 创建的。你应该看看他的书 Swift Secrets。
    我意识到 Playgrounds 的方法可能过于笨重,所以今天,我开源了。一个非常专注的名为 Inject 的微型库,与 InjectionForXcode 搭配使用时,将使您的 Apple 开发更加高效和愉快!
    但不要只相信我的话。看看 Alexandra 和 Nate 的反馈,在我将这个工作流程引入 The Browser Company 设置之前,他们已经非常精通了,这使得它更加令人印象深刻。


Inject

    这个小型库是完全通用的,无论您使用 UIKit、 AppKit 还是 SwiftUI,您都可以使用它。
    您无需为生产应用程序添加条件或删除 Inject 代码。它变成了无操作内联代码,将在非调试版本中被编译过程剥离。您可以在每个视图中集成一次,并持续使用数年。
    请参考 GitHub repo中关于配置项目的说明。现在让我们来看看您有哪些工作流程选项。


工作流

    SwiftUI
        只需要两行字就可以使任何 SwiftUI 启用实时编程,而当您这样做时,您将拥有比使用 Swift Previews 更快的工作流程,同时能够使用实际的生产数据。
        这是我的 Sourcery Pro 应用程序的示例,其中加载了我所有的实际数据和逻辑,使我能够即时快速迭代整个应用程序设计,而无需任何重新启动、重新加载或类似的事情。
        看看这个开发工作流程有多快吧,告诉我你宁愿在我每次接触代码时等待Xcode的重新构建和重新部署。


    UIKit / AppKit
        我们需要一种方法来清理标准命令式UI框架的代码注入阶段之间的状态。
        我创建了 Host 的概念并且在这种情况下工作的很好。有两个:

        - Inject.ViewHost
        - Inject.ViewControllerHost

        我们如何集成它?我们把我们想迭代的类包装在父级,因此我们不修改要注入的类型,而是改变父级的调用站点。
        例如,如果你有一个 SplitViewController ,它创建了 PaneA 和 PaneB ,而你想在PaneA 中迭代布局/逻辑代码,你就修改 SplitViewController 中的调用站点。

        paneA = Inject.ViewHost(
            PaneAView(whatever: arguments, you: want)
        )

        这就是你需要做的所有改变。注入现在允许你更改 PaneAView 中的任何东西,除了它的初始化API。这些变化将立即反映在你的应用程序中。


        一个更具体的例子?
        1、我下载了 Covid19 App
        2、添加 -Xlinker -interposable 到 Other Linker Flags
        3、交换了一行 Covid19TabController.swift:L63 行

        从这句:

        let vc = TwitterViewController(title: Tab.twitter.name, usernames: Twitter.content)

        替换为:

        let vc = Inject.ViewControllerHost(TwitterViewController(title: Tab.twitter.name, usernames: Twitter.content))

        现在,我可以在不重新启动应用程序的情况下迭代控制器设计。


这是如何运作的呢?

    Hosts 利用了自动闭包,因此每次您注入代码时,我们都会使用与最初相同的参数创建您类型的新实例,从而允许您迭代任何代码、内存布局和其他所有内容。你唯一不能改变的是你的初始化 API。


逻辑注入如何呢?

    像 MVVM / MVC 这样的标准架构可以获得免费的逻辑注入,重新编译你的类,当方法重新执行时,你已经在使用新代码了。
    如果像我一样,你喜欢 PointFree Composable Architecture,你可能想要注入 reducer 代码。Vanilla TCA 不允许这样做,因为 reducer 代码是一个免费功能,不能直接用注入替换,但我们在 The Browser Company 的分支 支持它。
    当我最初开始咨询 TBC 时,我想要的第一件事是将 Inject 和 XcodeInjection 集成到我们的工作流程中。公司管理层非常支持。
    如果您切换到我们的 TCA 分支(我们保持最新),你可以在 UI 和 TCA 层上使用 Inject 。


它有多可靠?

    没有什么是完美的,但我已经使用它十多年了。它比 Apple 技术(Playgrounds / Previews)可靠得多。
如果您投入时间学习它,它将为您和您的团队节省数千小时!

收起阅读 »

一些常见的HTTP返回码

一些常见的状态码为:·       200 – 服务器成功返回网页·       404 – 请求的网页不存在·&nbs...
继续阅读 »

一些常见的状态码为:

·      
200 – 服务器成功返回网页

·      
404 – 请求的网页不存在

·      
503 – 服务器超时

1xx(临时响应)

表示临时响应并需要请求者继续执行操作的状态码。











100(继续)



请求者应当继续提出请求。服务器返回此代码表示已收到请求的第一部分,正在等待其余部分。



101(切换协议)



请求者已要求服务器切换协议,服务器已确认并准备切换。


2xx (成功)

表示成功处理了请求的状态码。































200(成功)



服务器已成功处理了请求。通常,这表示服务器提供了请求的网页。如果是对您的 robots.txt 文件显示此状态码,则表示 Googlebot 已成功检索到该文件。



201(已创建)



请求成功并且服务器创建了新的资源。



202(已接受)



服务器已接受请求,但尚未处理。



203(非授权信息)



服务器已成功处理了请求,但返回的信息可能来自另一来源。



204(无内容)



服务器成功处理了请求,但没有返回任何内容。



205(重置内容)



服务器成功处理了请求,但没有返回任何内容。与 204 响应不同,此响应要求请求者重置文档视图(例如,清除表单内容以输入新内容)。



206(部分内容)



服务器成功处理了部分 GET
请求。


3xx (重定向) 

要完成请求,需要进一步操作。通常,这些状态码用来重定向。Google 建议您在每次请求中使用重定向不要超过 5 次。您可以使用网站管理员工具查看一下 Googlebot 在抓取重定向网页时是否遇到问题。诊断下的网络抓取页列出了由于重定向错误导致 Googlebot 无法抓取的网址。































300(多种选择)



针对请求,服务器可执行多种操作。服务器可根据请求者 (user agent) 选择一项操作,或提供操作列表供请求者选择。



301(永久移动)



请求的网页已永久移动到新位置。服务器返回此响应(对 GET HEAD 请求的响应)时,会自动将请求者转到新位置。您应使用此代码告诉 Googlebot 某个网页或网站已永久移动到新位置。



302(临时移动)



服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来响应以后的请求。此代码与响应 GET HEAD 请求的
301
代码类似,会自动将请求者转到不同的位置,但您不应使用此代码来告诉 Googlebot 某个网页或网站已经移动,因为 Googlebot 会继续抓取原有位置并编制索引。



303(查看其他位置)



请求者应当对不同的位置使用单独的 GET 请求来检索响应时,服务器返回此代码。对于除 HEAD 之外的所有请求,服务器会自动转到其他位置。



304(未修改)



自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。


如果网页自请求者上次请求后再也没有更改过,您应将服务器配置为返回此响应(称为 If-Modified-Since HTTP 标头)。服务器可以告诉
Googlebot
自从上次抓取后网页没有变更,进而节省带宽和开销。


.



305(使用代理)



请求者只能使用代理访问请求的网页。如果服务器返回此响应,还表示请求者应使用代理。



307(临时重定向)



服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来响应以后的请求。此代码与响应 GET HEAD 请求的
<a href=answer.py?answer=>301</a>
代码类似,会自动将请求者转到不同的位置,但您不应使用此代码来告诉 Googlebot 某个页面或网站已经移动,因为 Googlebot 会继续抓取原有位置并编制索引。


4xx(请求错误) 

这些状态码表示请求可能出错,妨碍了服务器的处理。







































































400(错误请求)



服务器不理解请求的语法。



401(未授权)



请求要求身份验证。对于登录后请求的网页,服务器可能返回此响应。



403(禁止)



服务器拒绝请求。如果您在
Googlebot
尝试抓取您网站上的有效网页时看到此状态码(您可以在 Google 网站管理员工具诊断下的网络抓取页面上看到此信息),可能是您的服务器或主机拒绝了 Googlebot 访问。



404(未找到)



服务器找不到请求的网页。例如,对于服务器上不存在的网页经常会返回此代码。


如果您的网站上没有 robots.txt 文件,而您在 Google 网站管理员工具诊断”标签的 robots.txt 上看到此状态码,则这是正确的状态码。但是,如果您有 robots.txt 文件而又看到此状态码,则说明您的 robots.txt 文件可能命名错误或位于错误的位置(该文件应当位于顶级域,名为 robots.txt)。


如果对于 Googlebot 抓取的网址看到此状态码(在诊断标签的 HTTP 错误页面上),则表示 Googlebot 跟随的可能是另一个页面的无效链接(是旧链接或输入有误的链接)。



405(方法禁用)



禁用请求中指定的方法。



406(不接受)



无法使用请求的内容特性响应请求的网页。



407(需要代理授权)



此状态码与 <a
href=answer.py?answer=35128>401
(未授权)</a>类似,但指定请求者应当授权使用代理。如果服务器返回此响应,还表示请求者应当使用代理。



408(请求超时)



服务器等候请求时发生超时。



409(冲突)



服务器在完成请求时发生冲突。服务器必须在响应中包含有关冲突的信息。服务器在响应与前一个请求相冲突的 PUT 请求时可能会返回此代码,以及两个请求的差异列表。



410(已删除)



如果请求的资源已永久删除,服务器就会返回此响应。该代码与 404(未找到)代码类似,但在资源以前存在而现在不存在的情况下,有时会用来替代
404
代码。如果资源已永久移动,您应使用 301 指定资源的新位置。



411(需要有效长度)



服务器不接受不含有效内容长度标头字段的请求。



412(未满足前提条件)



服务器未满足请求者在请求中设置的其中一个前提条件。



413(请求实体过大)



服务器无法处理请求,因为请求实体过大,超出服务器的处理能力。



414(请求的 URI 过长)



请求的 URI(通常为网址)过长,服务器无法处理。



415(不支持的媒体类型)



请求的格式不受请求页面的支持。



416(请求范围不符合要求)



如果页面无法提供请求的范围,则服务器会返回此状态码。



417(未满足期望值)



服务器未满足期望请求标头字段的要求。


5xx(服务器错误)

这些状态码表示服务器在处理请求时发生内部错误。这些错误可能是服务器本身的错误,而不是请求出错。



























500(服务器内部错误)



服务器遇到错误,无法完成请求。



501(尚未实施)



服务器不具备完成请求的功能。例如,服务器无法识别请求方法时可能会返回此代码。



502(错误网关)



服务器作为网关或代理,从上游服务器收到无效响应。



503(服务不可用)



服务器目前无法使用(由于超载或停机维护)。通常,这只是暂时状态。



504(网关超时)



服务器作为网关或代理,但是没有及时从上游服务器收到请求。



505HTTP 版本不受支持)



服务器不支持请求中所用的
HTTP
协议版本。














































 

收起阅读 »

安全对等问题:确保移动应用跨平台安全性

一段时间以来,人们都“知道”,作为移动平台的 Android 不如 iOS 安全,这已经成为常识。似乎除了消费者,每个人都知道。2021 年 8 月对 10000 名移动消费者进行的一项全球调查发现,iOS 和 Android 用户的安全期望基本一致。然而,尽...
继续阅读 »

一段时间以来,人们都“知道”,作为移动平台的 Android 不如 iOS 安全,这已经成为常识。似乎除了消费者,每个人都知道。2021 年 8 月对 10000 名移动消费者进行的一项全球调查发现,iOS 和 Android 用户的安全期望基本一致。

然而,尽管消费者有这样的期望,而且从本质上讲,一个移动平台并不一定比另一个平台更安全,但移动应用很少能实现 Android 和 iOS 的安全功能对等。事实上,许多移动应用甚至缺少最基本的安全保护措施。让我们看看这是为什么。

1

移动应用安全需要多层次防御

大多数安全专家和第三方标准组织都会同意,移动应用安全需要多层次防御,包括以下核心领域的多种安全特性:

  • 代码混淆和应用护盾(Application Shielding):保护移动应用的二进制文件和源代码,防止逆向工程。

  • 数据加密:保护应用中存储和使用的数据。

  • 安全通信:保护在应用和应用后端之间传递的数据,包括确保用于建立可信连接的数字证书的真实性和有效性。

  • 操作系统防护:保护应用免受未经授权的操作系统修改(如 rooting 和越狱)所影响。

开发人员应该在应用的 iOS 和 Android 版本中均衡地实现这些功能的组合,形成一致的安全防御。而且,他们应该在开发周期的早期添加这些功能——这个概念被称为安全“左移”。听起来很容易吧?理论上,是的,但在实践中,如果使用“传统”方法,要实现移动应用多层次安全防御实际上是相当困难的。

多年来,移动开发人员一直试图使用传统的工具集来实现应用内移动应用安全,包括第三方开源库、商业移动应用安全 SDK 或专用编译器。第一个主要的挑战是,移动应用的安全从来无法通过“银弹”实现。由于移动应用在不受保护的环境中运行,并存储和处理大量有价值的信息,有许多方法可以攻击它们。黑客有无穷无尽的、免费提供而又非常强大的工具集可以使用,而且可以全天候地研究和攻击应用而不被发现。

2

移动安全要求

因此,为了构建一个强大的防御体系,移动开发人员需要实施一个既“广”且“深”的多层次防御。所谓“广”,我指的是不同保护类别的多种安全特性,它们彼此相互补充,如加密和混淆。所谓“深”,我指的是每个安全特性都应该有多种检测或保护方法。例如,一个越狱检测 SDK 如果只在应用启动时进行检查,就不会很有效,因为攻击者很容易绕过。

或者考虑下反调试,这是一种重要的运行时防御,可以防止攻击者使用调试器来执行动态分析——他们会在一个受控的环境中运行应用,为的是了解或修改应用的行为。有许多类型的调试器——有一些基于 LLDB——是针对原生代码的,如 C++ 或 Objective C,其他的在 Java 或 Kotlin 层进行检查,诸如此类。每个调试器连接和分析应用的工作方式都略有不同。因此,为了使反调试防御奏效,应用需要识别正在使用的多种调试方法,并动态地进行恰当的防御,因为黑客会继续尝试不同的调试工具或方法,直到他们找到一个可以成功的。

3

防篡改

安全要求清单并不仅限于此。每个应用都需要防篡改功能,如校验和验证、预防二进制补丁,以及应用的重新打包、重新签名、模拟器和仿真器,等等。毫不夸张,仅是针对一个操作系统研究和实现这些功能或保护方法中的一项,就需要至少几个人周的开发时间。而且还要有一个前提,就是移动开发人员已经拥有特定安全领域的专业知识,但情况往往并非如此。复杂度可能会快速增加,到目前为止,我们只讨论了一个保护类别——运行时或动态保护。想象一下,如果提到的每个功能都需要一到两周的开发时间,那么实现全部安全特性得付出多大的时间成本。

4

防越狱 /Rooting

接下来,你还需要操作系统层面的保护,如防越狱 /rooting,在移动操作系统遭破坏的情况下保护应用。越狱 /rooting 使移动应用容易受到攻击,因为它允许对操作系统和文件系统进行完全的管理控制,破坏了整个安全模型。而且,仅仅检测越狱 /rooting 已经不够了,因为黑客们一直在不断地改进他们的工具。要说最先进的越狱和 rooting 工具,在 iOS 上是 Checkra1n,在 Android 上是 Magisk——还有许多其他的工具。其中,还有一些工具用于隐藏或掩盖活动及管理超级用户权限——通常授予恶意应用。朋友们,如果你使用 SDK 或第三方库实现了越狱或 rooting 检测,那么你的保护措施很有可能已经过时或者很容易被绕过,尤其是在没有对应用的源代码进行充分混淆的情况下。

5

代码混淆

如果你使用 SDK 或第三方库来实现安全防护,那在未混淆的应用中几乎没什么用——为什么?因为黑客使用 Hopper、IDA-pro 等开源工具,就可以很容易地反编译或反汇编,找到 SDK 的源代码,或使用类似 Frida 这样的动态二进制工具箱,注入他们自己的恶意代码,修改应用的行为,或简单地禁用安全 SDK。

代码混淆可以防止攻击者了解移动应用的源代码。而且,我们总是建议使用多种混淆方法,包括混淆本地代码或非本地代码和库,以及混淆应用的逻辑结构或控制流。例如,可以使用控制流混淆或重命名函数、类、方法、变量等来实现。不要忘了还要混淆调试信息。

从现实世界的数据中可以看出,大多数移动应用都缺乏足够的混淆,只混淆了应用的一小部分代码,这项对超过 100 万个 Android 应用的研究清楚地说明了这一点。正如该研究指出的那样,造成这种情况的原因是,对于大多数移动开发人员来说,依赖专用编译器的传统混淆方法实在是太复杂和费时,难以全面实施。相反,许多开发人员只实现了单一的混淆功能,或者只混淆了代码库的一小部分。在这项研究中,研究人员发现,大多数应用只实现了类名混淆,这本身很容易被攻陷。拿书打个比方,类名混淆本身就像是混淆了一本书的“目录”,但书中所有实际的页和内容却并没有混淆。这种表面的混淆相当容易被绕过。

6

数据保护和加密

接着说数据保护,你还需要借助加密来保护应用和用户数据——在移动应用中,有很多地方存储着数据,包括沙盒、内存以及应用的代码或字符串。要自己实现加密,有很多棘手的问题需要解决,包括密钥衍生(key derivation)、密码套件和加密算法组合、密钥大小及强度。许多应用使用了多种编程语言,每一种都需要不同的 SDK,或者会导致你无法控制的不兼容性,又或是需要你无法访问的依赖。而数据类型的差异也有复杂性增加和性能下降的风险。

然后,还有一个典型的问题,即在哪里存储加密密钥。如果密钥存储在应用内部,那它们可能会被反向工程的攻击者发现,然后他们就可以用来解密数据。这就是为什么我们说动态密钥生成是一个非常重要的功能。通过动态密钥生成,加密密钥只在运行时生成,而不会存储在应用或移动设备上。此外,密钥只使用一次,可以防止攻击者发现或截获它们。

那么传输中的数据呢?仅靠 TLS 是不够的,因为有很多方法可以侵入应用的连接。检查和验证 TLS 会话和证书很重要,这可以确保所有的证书和 CA 都是有效且真实的,受到行业标准加密的保护。这可以防止黑客获得 TLS 会话的控制权。然后还有证书固定,可以防止连接到遭到入侵的服务器,或保护服务器,拒绝遭到入侵的应用连接(例如,如果你的应用被变成了一个恶意机器人)。

7

欺诈、恶意软件、防盗版

最后,还有反欺诈、反恶意软件和反盗版保护,你可以在上述基线保护的基础上增加防护层,用于防止非常高级或专门的威胁。这些保护措施可能包括可以防止应用覆盖攻击、自动点击器、钩子框架和动态二进制工具、内存注入、键盘记录器、密钥注入或可访问性滥用的功能,所有这些都是移动欺诈或移动恶意软件的常用武器。

不难想象,即使是实现上述功能的一个子集,也需要大量的时间和资源。到目前为止,我只是谈了一个强大的安全防御所需的特性和功能。即使你内部有资源和所需的技能组合,那么拼凑出一个防御体系的行动挑战又是什么呢?让我们探讨一下开发团队可能会遇到的一些实施挑战。

8

不同平台和框架之间的实现差异

鉴于用于构建移动应用的 SDK/ 库及原生或非原生编程语言之间存在无数的框架差异和不兼容,开发人员将面临的下一个问题是如何分别为 Android 和 iOS 实现这些安全功能。虽然软件开发工具包(SDK)提供了一些标准安全功能,但没有 SDK 能普遍覆盖所有的平台或框架。

当开发人员试图使用 SDK 或开源库来实现移动应用安全时,所面临的一个主要挑战在于,这些方法都依赖于源代码,需要对应用代码进行修改。而结果是,这些方法中的每一个都明确地与应用所使用的特定编程语言绑定,并且还暴露给了各种编程语言或是这些语言和框架的包“依赖”。

通常,iOS 应用使用 Objective-C 或 Swift 构建,而 Android 应用使用 Java 或 Kotlin 以及使用 C 和 C++ 编写原生库。例如,假如你想对存储在 Android 和 iOS 应用中的数据进行加密。如果你找到了一些第三方 Android 加密库亦或是 Java 或 Kotlin 的 SDK,它们不一定适用于应用中使用的 C 或 C++ 代码部分(原生库)。

在 iOS 中也是如此。你浏览 StackOverflow 时可能会发现,在 Swift 中常用的 Cryptokit 框架对 Objective C 不起作用。

那么,非原生或跨平台应用呢?它们是完全不同的赛道,因为你要处理的是 JavaScript 等 Web 技术和 React Native、Cordova、Flutter 或 Xamarin 等非原生框架,它们无法直接(或根本不能)使用为原生语言构建的 SDK 或库。此外,对于非原生应用,你可能无法获得相关的源代码文件,从源头实现加密。

关于这个问题,有一个真实的例子,请看 Stack Overflow 上的这篇帖子。开发人员需要在一个 iOS 应用中实现代码混淆,其中 React Native(一个非原生框架)和 Objective C(一种原生编码语言)之间存在多个依赖关系。由于 iOS 项目中没有可以混淆 React Native 代码的内置库,开发人员需要使用一个外部包(依赖关系 #1)。此外,该外部包还依赖下游的一个库或包来混淆 JavaScript 代码(依赖关系 #2)。现在,如果第三方库的开发人员决定废弃该解决方案,会发生什么?我们的一个客户就面临着这样的问题,这导致他们的应用不符合 PCI 标准。

那么,你认为需要多少开发人员来实现我刚才描述的哪怕是一小部分功能?又需要多长时间?你有足够的时间在现有的移动应用发布过程中实现所需的安全功能吗?

9

DevOps 是敏捷 + 自动化,传统安全是单体 + 手动

移动应用是在一个快节奏、灵活且高度自动化的敏捷模式下开发和发布的。为了使构建和发布更快速、更简单,大多数 Android 和 iOS DevOps 团队都围绕 CI/CD 和其他自动化工具构建了最佳管道。另一方面,安全团队无法访问或查看 DevOps 系统,而且大多数安全工具并不是针对敏捷方法构建的,因为它们在很大程度上依赖于手动编程或实施,在这种情况下,单个安全功能的实施时间可能会长于发布时间表允许的时间。

为了弥补这些不足,一些组织在向公共应用商店发布应用之前,会使用代码扫描和渗透测试,以深入探查漏洞和其他移动应用问题。当发现漏洞时,企业就会面临一个艰难的决定:是在未进行必要保护的情况下发布应用,还是推迟发布,让开发人员有时间来解决安全问题。当这种情况发生时,推荐的安全保护措施往往会被忽视。

开发人员并不懒惰,而是他们用于实现安全保护的系统和工具根本无法匹配现代敏捷 /DevOps 开发的快节奏。

10

实现强大的移动应用安全和平台对等的五个步骤

一般来说,自动化是实现安全对等和强大的移动应用安全的关键所在。以下是在应用发布周期内将移动应用安全打造为应用组成部分的五个步骤。

第 1 步:明确希望得到什么样的安全成果

开发、运营和安全团队必须就移动安全预期达成一致。对于组织作为起点的安全目标,人们要有一个共同的理解,如 OWASP Mobile Top 10、TRM 移动应用安全指南和移动应用安全验证标准(MASVS)。一旦确定了目标并选择了标准,所有团队成员都要知道这对他们的工作流有何影响。

第 2 步:移动应用安全的实施必须自动化

安全非常复杂,手动编码很慢,而且容易出错。评估并利用自动化系统,借助人工智能和机器学习(ML)将安全集成到移动应用中。通常情况下,这些都是无代码平台,可以自动将安全构建到移动应用中,它们通常被称为安全构建系统。

第 3 步:将安全作为开发周期的一部分——安全左移

移动应用安全模型左移是指,移动开发人员需要在构建应用的同时构建安全特性。

一旦选择了自动化安全实施平台,就应该将其整合到团队的持续集成(CI)和持续交付(CD)流程中,这可以加速开发生命周期,所有团队——开发、运营和安全——在整个冲刺期间都应该保持密切合作。此外,企业可以为每个 Android 和 iOS 应用所需的特定安全特性创建可重复使用的移动安全模板,从而更接近实现平台对等。

第 4 步:确保即时确认和验证

如果没有办法即时验证所需的安全功能是否包含在发布中,那么在发布会议上就会出现争执,可能导致应用发布或更新延期。验证和确认应该自动记录,防止最后一刻的发布混乱。

第 4 步:确保即时确认和验证

开发团队需要可预测性和明确的预算。通过采用自动化的安全方法,应用开发团队可以减少人员和开发费用的意外变化,因为它消除了手动将安全编码到移动应用时固有的不确定性。

11

小结

安全对等问题是一个大问题,但它是一个更大问题的一部分,即移动应用普遍缺乏安全性。通过在安全实现中采用与特性开发相同或更高程度的自动化,开发人员可以确保他们针对每个平台发布的每一个应用都免受黑客、骗子和网络犯罪分子的侵害。

作者简介:

Alan Bavosa 是 Appdome 的安全产品副总裁。长期以来,他一直担任安全产品执行官,曾是 Palerra(被 Oracle 收购)和 Arcsight(被 HP 收购)的产品主管。

原文链接:

https://www.infoq.com/articles/secure-mobile-apps-parity-problem/


收起阅读 »

iOS-底层原理 04:NSObject的alloc 源码分析

iOS
主要自定义类的alloc的alloc的源码实现中加一个断点,同时需要暂时关闭断点运行target,断点断在alloc源码的断点,然后继续执行,会出现以下这种现象探索Why【第一步】探索Debug --> Debug Workflow --> 勾选 ...
继续阅读 »

主要NSObject中的alloc是与自定义类的alloc源码流程的区别,以及为什么NSObject中的alloc不走源码工程。

上一篇文章中分析了alloc的源码,这篇文章是作为对上一篇文章的补充,去探索为什么NSObject的alloc方法不走源码工程。

NSObject的alloc无法进入源码的问题

首先在objc4-781可编译源码中的main函数中增加一个NSObject定义的对象,NSObject 和 LGPersong同时加上断点



alloc的源码实现中加一个断点,同时需要暂时关闭断点


运行target,断点断在NSObject部分,打开alloc源码的断点,然后继续执行,会出现以下这种现象


探索Why

【第一步】探索[NSObject alloc]走的是哪步源码

接下来,我们就来探索为什么NSObject的alloc会出现这种情况,首先,

  • 打开Debug --> Debug Workflow --> 勾选 Always Show Disassemly,开启汇编调试

    关闭源码的断点,只留main中的断点,重新运行程序,然后通过下图的汇编可以发现NSObject并没有走 alloc源码,而是走的objc_alloc


然后关闭汇编调试,在全局搜索 objc_alloc,在objc_alloc中加一个断点,先暂时关闭,


重新运行进行调试,断住,然后打开objc_alloc的断点,发现会进入objc_alloc的源码实现,此时查看 cls 是 NSObject


【第二步】探索 NSObject 为什么走 objc_alloc?

首先,我们来看看 NSObject 与 LGPerson的区别

  • NSObject 是iOS中的基类,所有自定义的类都需要继承自NSObject
  • LGPerson 是继承NSObject类的,重写NSObject中的alloc方法

然后根据第一步中汇编的显示,可以看出,NSObject 和 LGPerson 都调用了objc_alloc,所以这里就有两个疑问

  • 为什么NSObject 调用alloc方法 会走到 objc_alloc 源码?
  • 为什么LGPerson中的alloc 会走两次?即调用了alloc,进入源码,然后还要走到 objc_alloc

LGPerson中alloc 走两次 的 Why?

首先,需要在源码中调试,在mainLGPerson加断点,断在LGPerson,再在alloc 、 objc_alloc 和 calloc 源码加断点,运行demo,会断在objc_alloc源码中(重新运行前需要暂时关闭源码中的所有断点)


继续运行,发现LGPerson 第一次的alloc会走到 objc_alloc --> callAlloc方法中最下方的objc_msgSend,表示向系统发送消息



所以由上述调试过程可以得出,LGPerson两次的原因是首先需要去查找sel,以及对应的imp的关系,当前需要查找的是 alloc的方法编号,但是为什么会找到objc_alloc?这个就需要问系统了,肯定是系统在底层做了一些操作。请接着往下看

NSObject中alloc 走到 objc_alloc 的 why?

这部分需要通过 LLVM源码(即llvm-project) 来分析

准备工作:首先需要一份llvm源码

在llvm源码中搜索objc_alloc


搜索shouldUseRuntimeFunctionForCombinedAllocInit,表示版本控制


搜索tryEmitSpecializedAllocInit,非常著名的特殊消息发送,在这里也没有找到 objc_alloc


继续尝试,开启上帝视角,通过alloc字符串搜索,如果还找不到,还可以通过omf_alloc:找到tryGenerateSpecializedMessageSend,表示尝试生成特殊消息发送


然后在这个case中可以找到调用alloc,转而调用了objc_objc的逻辑,其中的关键代码是EmitObjCAlloc


跳转至EmitObjCAlloc的定义可以看到alloc 的处理是调用了 objc_alloc


由此可以得出 NSObject中的alloc 会走到 objc_alloc,其实这部分是由系统级别的消息处理逻辑,所以NSObject的初始化是由系统完成的,因此也不会走到alloc的源码工程中

总结

总结下NSObject中alloc 和自定义类中alloc的调用流程

NSObject


自定义类


作者:style_月月
链接:https://blog.csdn.net/lin1109221208/article/details/108480971

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

iOS 底层原理03:objc4-781 源码编译 & 调试

iOS
准备工作环境版本 & 最新objc源码mac OS 10.15Xcode 11.4objc4-781依赖文件下载需要下载以下依赖文件源码编译源码编译就是不断的调试修改源码的问题,主要有以下问题问题一:unable to find sdk 'macosx...
继续阅读 »

准备工作

环境版本 & 最新objc源码

  • mac OS 10.15
  • Xcode 11.4
  • objc4-781

依赖文件下载

需要下载以下依赖文件


源码编译

源码编译就是不断的调试修改源码的问题,主要有以下问题

问题一:unable to find sdk 'macosx.internal'


选择 target -> objc -> Build Settings -> Base SDK -> 选择 macOS 【target中的 objc 和 obc-trampolines都需要更改】


问题二:文件找不到的报错问题

【1】‘sys/reason.h’ file not found


在Apple source的 macOS10.15 --> xnu-6153.11.26/bsd/sys/reason.h 路径自行下载

在objc4-781的根目录下新建CJLCommon文件, 同时在CJLCommon文件中创建sys文件

最后将 reason.h文件拷贝到sys文件中

设置文件检索路径:选择 target -> objc -> Build Settings,在工程的 Header Serach Paths 中添加搜索路径 $(SRCROOT)/CJLCommon

【2】‘mach-o/dyld_priv.h’ file not found

  • CJLCommon文件中 创建 mach-o 文件
  • 找到文件:dyld-733.6 -- include -- mach-o -- dyld_priv.h


拷贝到 mach-o文件中



  • 拷贝到文件后,还需要修改 dyld_priv.h 文件,即在 dyld_priv.h文件顶部加入一下宏:


【3】‘os/lock_private.h’ file not found 和 ‘os/base_private.h’ file not found

  • 在CJLCommon中创建 os文件
  • 找到lock_private.h、base_private.h文件:libplatform-220 --> private --> os --> lock_private.h 、base_private.h,并将文件拷贝至 os 文件中

【4】‘pthread/tsd_private.h’ file not found 和 ‘pthread/spinlock_private.h’ file not found

在CJLPerson中创建 pthread 文件
找到tsd_private.h、spinlock_private.h文件,h文件路径为:libpthread-416.11.1 --> private --> tsd_private.h、spinlock_private.h,并拷贝到 pthread文件


【5】‘System/machine/cpu_capabilities.h’ file not found

创建 System -- machine 文件
找到 cpu_capabilities.h文件拷贝到 machine文件,h文件路径为:xnu6153.11.26 --> osfmk --> machine --> cpu_capabilities.h


【6】os/tsd.h’ file not found

找到 tsd.h文件,拷贝到os文件, h文件路径为:xnu6153.11.26 --> libsyscall --> os --> tsd.h


【7】‘System/pthread_machdep.h’ file not found

  • 这个地址下载pthread_machdep.h文件,h文件路径为:Libc-583/pthreads/pthread_machdep.h
  • 将其拷贝至system文件中


【8】‘CrashReporterClient.h’ file not found

导入下载的还是报错,可以通过以下方式解决
- 需要在 Build Settings -> Preprocessor Macros 中加入:LIBC_NO_LIBCRASHREPORTERCLIENT
- 或者下载我给大家的文件CrashReporterClient,这里面我们直接更改了里面的宏信息 #define LIBC_NO_LIBCRASHREPORTERCLIENT

【9】‘objc-shared-cache.h’ file not found

文件路径为:dyld-733.6 --> include --> objc-shared-cache.h


  • 将h文件报备制拷贝到CJLCommon

【10】Mismatch in debug-ness macros

注释掉objc-runtime.mm中的#error mismatch in debug-ness macros


【11】’_simple.h’ file not found

文件路径为:libplatform-220 --> private --> _simple.h


  • 将文件拷贝至CJLCommon
【12】‘kern/restartable.h’ file not found

  • 在CJLCommon中创建kern 文件
  • 找到 h文件,路径为xnu-6153.11.26 --> osfmk --> kern -->restartable.h


【13】‘Block_private.h’ file not found

找到 h 文件,文件路径为libclosure-74 --> Block_private.h



拷贝至CJLCommon目录

【14】libobjc.order 路径问题

问题描述为:can't open order file: /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/AppleInternal/OrderFiles/libobjc.order

  • 选择 target -> objc -> Build Settings
  • 在工程的 Order File 中添加搜索路径 $(SRCROOT)/libobjc.order



【14】Xcode 脚本编译问题
问题描述为:/xcodebuild:1:1: SDK "macosx.internal" cannot be located.

选择 target -> objc -> Build Phases -> Run Script(markgc)
把脚本文本 macosx.internal 改成 macosx


编译调试

新建一个target :CJLTest



绑定二进制依赖关系



源码调试

自定义一个CJLPerson类

image

在main.m中 创建 CJLPerson的对象,进行源码调试



作者:style_月月
链接:https://blog.csdn.net/lin1109221208/article/details/108435967

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

现今 Swift 包中的二进制目标

一、目录      1、理解二进制在 Swift 中的演变    2、命令行工具相关    3、结论二、前言    在 iOS 和...
继续阅读 »

一、目录  

    1、理解二进制在 Swift 中的演变
    2、命令行工具相关
    3、结论

二、前言

    在 iOS 和 macOS 开发中, Swift 包现在变得越来越重要。Apple 已经努力推动桥接那些缝隙,并且修复那些阻碍开发者的问题,例如阻碍开发者将他们的库和依赖由其他诸如 Carthage 或 CocoaPods依赖管理工具迁移到 Swift 包依赖管理工具的问题,例如没有能力添加构建步骤的问题。这对任何依赖一些代码生成的库来说都是破坏者,比如,协议和 Swift 生成。


    1、理解二进制在 Swift 中的演变

        为了充分理解 Apple 的 Swift 团队在二进制目标和他们引入的一些新 API 方面采取的一些步骤,我们需要理解它们从何而来。在后续的部分中,我们将调研 Apple 架构的演变,以及为什么二进制目标的 API 在过去几年中逐渐形成的,特别是自 Apple 发布了自己的硅芯片之后。


        胖二进制和 Frameworks 框架

        如果你曾必须处理二进制依赖,或者你曾创建一个属于你自己的可执行文件,你将会对 胖二进制 这个术语感到熟悉。这些被扩展(或增大)的可执行文件,是包含了为多个不同架构原生构建的切片。这允许库的所有者分发一个运行在所有预期的目标架构上的单独的二进制。
        当源码不能被暴露或当处理非常庞大的代码仓库时,预编译库成为可执行文件非常有意义,因为预编译源码以及以二进制文件分发他们,将节省构建程序在他们的应用上的构建时间。
        Pods 是一个非常好的例子,当开发者发现他们自己没必要构建那些非常少改动的依赖。这是一个很共通的问题,它激发了诸如 cocoapods-binary之类的项目,该项目预编译了 pod 依赖项以减少客户端的构建时间。


        Frameworks 框架

        嵌入静态二进制文件可能对应用程序来说已经足够了,但如果需要某些资源(如 assets 或头文件),则需要将这些资源与包含所有切片的 胖二进制文件 捆绑在一起,形成所谓的 frameworks 文件。
这就是诸如 Google Cast[5] 之类的预编译库在过渡到使用 xcframework 进行分发之前所做的事情 —— 下一节将详细介绍这种过渡的原因。
        到目前为止,一切都很好。如果我们要为分发预编译一个库,那么胖二进制文件听起来很理想,对吧?并且,如果我们需要捆绑一些其他资源,我们可以只使用一个 frameworks。一个二进制来统治他们所有!


        XCFrameworks 框架

        好吧,不完全是。胖二进制文件有一个大问题,那就是你不能有两个架构相同但命令/指令不同的切片。这曾经很好,因为设备和模拟器的架构总是不同的,但是随着 Apple Silicon 计算机 (M1) 的推出,模拟器和设备共享相同的架构 (arm64),但具有不同的加载器命令。这与面向未来的二进制目标相结合,正是 Apple 引入 XCFrameworks 的原因。
        XCFrameworks现在允许将多个二进制文件捆绑在一起,解决了 M1 Mac 引入的设备和模拟器冲突架构问题,因为我们现在可以为每个用例提供包含相关切片的二进制文件。事实上,如果我们需要,我们可以走得更远,例如,在同一个 xcframework 中捆绑一个包含 iOS 目标的 UIKit 接口的二进制文件和一个包含 macOS 的 AppKit 接口的二进制文件,然后让 Xcode 基于期望的目标架构决定使用哪一个。
        在 Swift 包中,那先能够以 binaryTarget 被包含进项目的,能够在包中被引入任意其他目标。这相同的操作同样适用于 frameworks。


     2、命令行工具相关

        由于 Swift 5.6 版本中引入了用于 Swift 包管理器的 可扩展构建工具[9] ,因此可以在构建过程中的不同时间执行命令。

        这是 iOS 社区长期以来一直强烈要求的事情,例如格式化源代码、代码生成甚至收集公制代码库的指标。Swift 5.6 中所有这些所谓的 插件最终都需要调用可执行文件来执行特定任务。这是二进制文件再次在 Swift 包中参与的地方。
        在大多数情况下,对于我们 iOS 开发人员来说,这些工具将来自同时支持 macOS 的不同架构切片 —— Apple Silicon 的 arm64 架构和 Intel Mac 的 x86_64 架构。开发者工具如, SwiftLint或 SwiftGen 正是这种案例。在这种情况下,可以使用包含可执行文件(本地或远程)的 .zip 文件的路径创建新的二进制目标。


        Artifact Bundles

        到目前为止,命令行工具所采用的方法仅适用于 macOS 架构。但我们不能忘记,Linux 机器也支持 Swift 包。这意味着如果要同时支持 M1 macs (arm64) 和 Linux arm64 机器,上面的胖二进制方法将不起作用 —— 请记住,二进制不能包含具有相同架构的多个切片。在这个阶段可能有人会想,我们可以不只使用 xcframeworks 吗?不,因为它们在 Linux 操作系统上不受支持!
        Apple 已经考虑到这一点,除了引入 可扩展构建工具[13] 之外,Artifact Bundles和对二进制目标的其他改进也作为 Swift 5.6 的一部分发布。
        工件包(Artifact Bundles) 是包含 工件 的目录。这些工件需要包含支持架构的所有不同二进制文件。二进制文件和支持的架构的路径是使用清单文件 (info.json) 指定的,该文件位于 Artifact Bundle 目录的根目录中。你可以将此清单文件视为一个地图或指南,以帮助 Swift 确定哪些可执行文件可用于哪种架构以及可以在哪里找到它们。


        以 SwiftLint 为例

        SwiftLint 在整个社区中被广泛用作 Swift 代码的静态代码分析工具。由于很多人都非常渴望让这个插件在他们的 SwiftPM 项目中运行,我认为这将是一个很好的例子来展示我们如何将分发的可执行文件从他们的发布页面变成一个与 macOS 架构和 Linux arm64 兼容的工件包。
        让我们从下载两个可执行文件(macOS 和 Linux)开始。
        至此,bundle的结构就可以创建好了。为此,创建一个名为 swiftlint.artifactbundle 的目录并在其根目录添加一个空的 info.json:

        mkdir swiftlint.artifactbundle

        touch swiftlint.artifactbundle/info.json

        现在可以使用 schemaVersion 填充清单文件,这可能会在未来版本的工件包和具有两个变体的工件中发生变化,这将很快定义:

        {

            "schemaVersion": "1.0",
            "artifacts": {
                "swiftlint": {
                    "version": "0.47.0", # The version of SwiftLint being used
                    "type": "executable",
                    "variants": [
                    ]
                },
            }
        }

        需要做的最后一件事是将二进制文件添加到包中,然后将它们作为变体添加到 info.json 文件中。让我们首先创建目录并将二进制文件放入其中(macOS 的一个在 swiftlint-macos/swiftlint,Linux 的一个在 swiftlint-linux/swiftlint)。
        添加这些之后,可以在清单文件中变量:

        {
            "schemaVersion": "1.0",
            "artifacts": {
                "swiftlint": {
                    "version": "0.47.0", # The version of SwiftLint being used
                    "type": "executable",
                    "variants": [
                    {
                        "path": "swiftlint-macos/swiftlint",
                        "supportedTriples": ["x86_64-apple-macosx", "arm64-apple-macosx"]
                    },
                    {
                        "path": "swiftlint-linux/swiftlint",
                        "supportedTriples": ["x86_64-unknown-linux-gnu"]
                    },
                    ]
                },
            }
        }

        为此,需要为每个变量指定二进制文件的相对路径(从工件包目录的根目录)和支持的三元组。如果您不熟悉 目标三元组,它们是一种选择构建二进制文件的架构的方法。请注意,这不是 主机(构建可执行文件的机器)的体系结构,而是 目标 机器(应该运行所述可执行文件的机器)。

        这些三元组具有以下格式: ---- 并非所有字段都是必需的,如果其中一个字段未知并且要使用默认值,则可以省略或替换为 unknown 关键字。
        可执行文件的架构切片可以通过运行 file 找到,这将打印捆绑的任何切片的供应商、系统和架构。在这种情况下,为这两个命令运行它会显示:


        swiftlint-macos/swiftlint


        swiftlint: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64]

        swiftlint (for architecture x86_64): Mach-O 64-bit executable x86_64

        swiftlint (for architecture arm64): Mach-O 64-bit executable arm64


        swiftlint-linux/swiftlint


        -> file swiftlint
        swiftlint: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, with debug_info, not stripped


       这带来了上面显示的 macOS 支持的两个三元组(x86_64-apple-macosx、arm64-apple-macosx)和 Linux 支持的一个三元组(x86_64-unknown-linux-gnu)。

        与 XCFrameworks 类似,工件包也可以通过使用 binaryTarget 包含在 Swift 包中。


    3、结论

        简而言之,我们可以总结 2022 年如何在 Swift 包中使用二进制文件的最佳实践,如下所示:

        1、如果你需要为你的 iOS/macOS 项目添加预编译库或可执行文件,您应该使用 XCFramework,并为每个用例(iOS 设备、macOS 设备和 iOS 模拟器)包含单独的二进制文件。
        2、如果你需要创建一个插件并运行一个可执行文件,你应该将其嵌入为一个工件包,其中包含适用于不同支持架构的二进制文件。

收起阅读 »

怎么看待996

近日有网传消息称“现在的乐视仍有400多名员工,过着没有‘老板’的神仙日子,无996无内卷,公司没有拖欠过工资和社保,当年无敌高端配置的电视日活仍然不错,靠运营和版权收入养活员工”。在此感谢大家的关注,情况基本属实,我们再稍作补充:1、乐视确实没有996,而且...
继续阅读 »

近日有网传消息称“现在的乐视仍有400多名员工,过着没有‘老板’的神仙日子,无996无内卷,公司没有拖欠过工资和社保,当年无敌高端配置的电视日活仍然不错,靠运营和版权收入养活员工”。在此感谢大家的关注,情况基本属实,我们再稍作补充:
1、乐视确实没有996,而且以后也不会有。工作是永远做不完的,在有限的时间内完成有限的工作,这合情合理。如果有一天,我们合法地率先推行每周工作四天半、36小时工作制,大家也不要感到意外。
2、乐视员工“无内卷”过于绝对了,毕竟有人的地方就有“江湖”。只不过在乐视,“内卷”的程度低一些,因为员工只有400多,很多岗位是“一个萝卜一个坑”,跟谁卷?但凡多一两个人可能就卷起来了。
3、公司近五年确实从未拖欠过员工工资和社保。
4、“没有老板的神仙日子”这个说法我们尚高攀不起,神仙日子般的工作基本会是任何员工的一种奢求,如果能让员工觉得“工作似神仙”那公司一定很成功。而“老板”这个用词这些年本就是一个相对模糊的概念,不同语境有不同含义。很多企业部门员工私下称部门负责人为老板,部门负责人称CEO为老板,CEO称董事长、创始人、实际控制人为老板。按此理解,乐视会有很多“老板”,各业务负责人是老板,CEO、董事长是老板,股东拜访公司我们也称老板,创始人贾跃亭先生也是老板,原战略股东“融创”来也是老板。所以说,乐视不是没有老板,也许是因为公司内部各业务条线的“老板”们勤勤恳恳、尽职尽责,不需要其他上级“老板”亲力亲为,才让大家觉得没有老板。
5、乐视超级电视当年的配置确实无敌,即便是14年、15年和16年的产品,配置的Mstar系列918/928/938等当时的旗舰芯片,现在依然运行速度飞快,不逊于当下其他品牌的主流配置互联网电视,乐视超级电视如今的日活离不开满级性能配置的策略。乐视生态曾经的理念是 “两倍性能一半价格”,如今虽已告别烧钱模式,但仍坚持以“同等性能更低价格”来做电视、手机等新品,请大家多多关注和支持我们的业务进展。

收起阅读 »

MySQL:max_allowed_packet 影响了什么?

数据包如果要发送超过 16M 的数据怎么办?那怎样算一个数据包?下面我们通过测试来讨论 max_allowed_packet 的实际影响。如果 SQL 文件中有单个 SQL 大小超过 max_allowed_packet ,会报错:##导出时设置 mysqld...
继续阅读 »

max_allowed_packet 表示 MySQL Server 或者客户端接收的 packet 的最大大小,packet 即数据包,MySQL Server 和客户端上都有这个限制。

数据包

每个数据包,都由包头、包体两部分组成,包头由 3 字节的包体长度、1 字节的包编号组成。3 字节最多能够表示 2 ^ 24 = 16777216 字节(16 M),也就是说,一个数据包的包体长度必须小于等于 16M 。

如果要发送超过 16M 的数据怎么办?

当要发送大于 16M 的数据时,会把数据拆分成多个 16M 的数据包,除最后一个数据包之外,其它数据包大小都是 16M。而 MySQL Server 收到这样的包后,如果发现包体长度等于 16M ,它就知道本次接收的数据由多个数据包组成,会先把当前数据包的内容写入缓冲区,然后接着读取下一个数据包,并把下一个数据包的内容追加到缓冲区,直到读到结束数据包,就接收到客户端发送的完整数据了。

那怎样算一个数据包?

  • 一个 SQL 是一个数据包

  • 返回查询结果时,一行数据算一个数据包

  • 解析的 binlog ,如果用 mysql 客户端导入,一个 SQL 算一个数据包

  • 在复制中,一个 event 算一个数据包

下面我们通过测试来讨论 max_allowed_packet 的实际影响。

导入 SQL 文件受 max_allowed_packet 限制吗?

如果 SQL 文件中有单个 SQL 大小超过 max_allowed_packet ,会报错:

##导出时设置 mysqldump --net-buffer-length=16M,这样保证导出的sql文件中单个 multiple-row INSERT 大小为 16M
mysqldump -h127.0.0.1 -P13306 -uroot -proot --net-buffer-length=16M \
--set-gtid-purged=off sbtest sbtest1 > /data/backup/sbtest1.sql

##设置max_allowed_packet=1M

##导入报错
[root@localhost data]# mysql -h127.0.0.1 -P13306 -uroot -proot db3 < /data/backup/sbtest1.sql
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 1153 (08S01) at line 41: Got a packet bigger than 'max_allowed_packet' bytes

导入解析后的 binlog 受 max_allowed_packet 限制吗?

row 格式的 binlog,单个SQL修改的数据产生的 binlog 如果超过 max_allowed_packet,也会报错。

在恢复数据到指定时间点的场景,解析后的binlog单个事务大小超过1G,并且这个事务只包含一个SQL,此时一定会触发 max_allowed_packet 的报错。但是恢复数据的任务又很重要,怎么办呢?可以将 binlog 改名成 relay log,用 sql 线程回放来绕过这个限制。

查询结果受 max_allowed_packet 限制吗?

查询结果中,只要单行数据不超过客户端设置的 max_allowed_packet 即可:

##插入220M大小的数据
[root@localhost tmp]# dd if=/dev/zero of=20m.img bs=1 count=0 seek=20M
记录了0+0 的读入
记录了0+0 的写出
0字节(0 B)已复制,0.000219914 秒,0.0 kB/秒
[root@localhost tmp]# ll -h 20m.img
-rw-r--r-- 1 root root 20M 6月   6 15:15 20m.img

mysql> create table t1(id int auto_increment primary key,a longblob);
Query OK, 0 rows affected (0.03 sec)

mysql> insert into t1 values(NULL,load_file('/tmp/20m.img'));
Query OK, 1 row affected (0.65 sec)

mysql> insert into t1 values(NULL,load_file('/tmp/20m.img'));
Query OK, 1 row affected (0.65 sec)

##mysql客户端默认 --max-allowed-packet=16M,读取失败
mysql> select * from t1;
ERROR 2020 (HY000): Got packet bigger than 'max_allowed_packet' bytes

##设置 mysql 客户端 --max-allowed-packet=22M,读取成功
[root@localhost ~]# mysql -h127.0.0.1 -P13306 -uroot -proot --max-allowed-packet=23068672 sbtest -e "select * from t1;" > /tmp/t1.txt

[root@localhost ~]# ll -h /tmp/t1.txt
-rw-r--r-- 1 root root 81M 6月   6 15:30 /tmp/t1.txt

load data 文件大小受 max_allowed_packet 限制吗?

load data 文件大小、单行大小都不受 max_allowed_packet 影响:

##将上一个测试中的数据导出,2行数据一共81M
mysql> select * int o outfile '/tmp/t1.csv' from t1;
Query OK, 2 rows affected (0.57 sec)

[root@localhost ~]# ll -h /tmp/t1.csv
-rw-r----- 1 mysql mysql 81M 6月   6 15:32 /tmp/t1.csv

##MySQL Server max_allowed_packet=16M
mysql> select @@max_allowed_packet;
+----------------------+
| @@max_allowed_packet |
+----------------------+
|             16777216 |
+----------------------+
1 row in set (0.00 sec)

##load data 成功,不受 max_allowed_packet 限制
mysql> load data infile '/tmp/t1.csv' into table t1;
Query OK, 2 rows affected (1.10 sec)
Records: 2 Deleted: 0 Skipped: 0 Warnings: 0

binlog 中超过 1G 的 SQL ,是如何突破 max_allowed_packet 复制到从库的?

从库 slave io 线程、slave sql 线程可以处理的最大数据包大小由参数 slave_max_allowed_packet 控制。这是限制 binlog event 大小,而不是单个 SQL 修改数据的大小。

主库 dump 线程会自动设置 max_allowed_packet为1G,不会依赖全局变量 max_allowed_packet。用来控制主库 DUMP 线程每次读取 event 的最大大小。

具体可以参考:mp.weixin.qq.com/s/EfNY_UwEthiu-DEBO7TrsA

另外超过 4G 的大事务,从库心跳会报错:https://opensource.actionsky.com/20201218-mysql/

作者:胡呈清,爱可生 DBA 团队成员,擅长故障分析、性能优化

来源:jianshu.com/u/a95ec11f67a8

收起阅读 »

tinaJs 源码分析

是什么为了避免混淆 tina 和原生的一些概念,这里先说明一下一些词的含义开局先来预览一下 Page.define 的流程// tina/class/page.jsclass Page extends Basic {  static mixins =...
继续阅读 »

目前公司团队小程序框架使用的是 tinaJs,这篇文章将讲解这个框架的源码。阅读文章时可以对照着这个小工程阅读源码,这个小工程主要是对 tina 加了更多的注释及示例。

是什么

tinaJs 是一款轻巧的渐进式微信小程序框架,不仅能充分利用原生小程序的能力,还易于调试。
这个框架主要是对 Component、Page 两个全局方法进行了封装,本文主要介绍 tinaJS 1.0.0 的 Paeg.define 内部做了些什么。Component.definePaeg.define相似,理解 Paeg.define 之后自然也就理解 Component.define。为什么是讲解 1.0.0 ?因为第一个版本的代码相对于最新版本主干内容更更清晰更容易上手。


概览

为了避免混淆 tina 和原生的一些概念,这里先说明一下一些词的含义

  • wx-Page - 原生 Page 对象

  • tina-Page - tina/class/page 这个类

  • wxPageOptions - 构建原生 Page 实例的 options

  • tinaPageOptions - 构建原生 tina-Page 实例的 options

开局先来预览一下 Page.define 的流程

// tina/class/page.js
class Page extends Basic {
 static mixins = []
 static define(tinaPageOptions = {}) {
   // 选项合并
   tinaPageOptions = this.mix(/*....*/)
   
   // 构建原生 options 对象
   let wxPageOptions = {/*.....*/}
   
   // 在原生 onLoad 时做拦截,关联 wx-Page 对象和 tina-Page 对象
   wxPageOptions = prependHooks(wxPageOptions, {
     onLoad() {
       // this 是小程序 wx-Page 实例
       // instance 是这个 tina-Page 实例
       let instance = new Page({ tinaPageOptions })
       // 建立关联
       this.__tina_instance__ = instance
       instance.$source = this
    }
  })
   
   // 构造 wx-Page 对象
   new globals.Page({
      // ...
      ...wxPageOptions,
    })
}
 constructor({ tinaPageOptions = {} }) {
   super()
   //.......
}
 get data() {
  return this.$source.data
}
}

下面针对每个小流程做讲解

mix

tina 的 mixin 是靠 js 对对象做合并实现的,并没有使用原生的 behaviors

tinaPageOptions = this.mix(PAGE_INITIAL_OPTIONS, [...BUILTIN_MIXINS, ...this.mixins, ...(tinaPageOptions.mixins || []), tinaPageOptions])

tinaJs 1.0.0 只支持一种合并策略,跟 Vue 的默认合并策略一样

  • 对于 methods 就是后面的覆盖前面的

  • 对于生命周期勾子和特殊勾子(onPullDownRefresh 等),就是变成一个数组,还是后面的先执行

  • 也就是 tinaPageOptions.mixins > Page.mixins(全局 mixin) > BUILTIN_MIXINS

合并后可以得到这样一个对象

{
// 页面
beforeLoad: [$log.beforeLoad, options.beforeLoad],
onLoad: [$initial.onLoad, options.onLoad],
onHide: [],
onPageScroll: [],
onPullDownRefresh: [],
onReachBottom: [],
onReady: [],
onShareAppMessage: [],
onShow: [],
onUnload: [],
// 组件
attached: Function,
compute: Function,
created: $log.created,
// 页面、组件共用
data: tinaPageOptions.data,
methods: tinaPageOptions.methods,
mixins: [],
}

合并后是创建 wx-Page 对象,至于创建 wx-Page 对象过程做了什么,为了方便理解整个流程,在这里暂时先跳过讲解,放在后面 改变执行上下文 小节再讲解。

关联 wx-Page、tina-Page

为了绑定 wx-Page 对象,tina 在 wx-onLoad 中追加了一些操作。
prependHooks 是作用是在 wxPageOptions[hookName] 执行时追加 handlers[hookName] 操作,并保证 wxPageOptions[hookName]handlers[hookName] 的执行上下文是原生运行时的 this

// tina/class/page
wxPageOptions = prependHooks(wxPageOptions, {
 onLoad() {
   // this 是 wxPageOptions
   // instance 是 tina-Page 实例
   let instance = new Page({ tinaPageOptions })
   // 建立关联
   this.__tina_instance__ = instance
   instance.$source = this
}
})


// tina/utils/helpers.js

/**
* 在 wx-page 生命周期勾子前追加勾子
* @param {Object} context
* @param {Array} handlers
* @return {Object}
*/
export const prependHooks = (context, handlers) =>
addHooks(context, handlers, true)

function addHooks (context, handlers, isPrepend = false) {
 let result = {}
 for (let name in handlers) {
   // 改写 hook 方法
   result[name] = function handler (...args) {
     // 小程序运行时, this 是 wxPageOptions
     if (isPrepend) {
       // 执行 tina 追加的 onLoad
       handlers[name].apply(this, args)
    }
     if (typeof context[name] === 'function') {
       // 执行真正的 onLoad
       context[name].apply(this, args)
    }
     // ...
  }
}
 return {
   ...context,
   ...result,
}
}

构建 tina-Page

接下来再来看看 new Page 做了什么

  constructor({ tinaPageOptions = {} }) {
   super()
   // 创建 wx-page options
   let members = {
     // compute 是 tina 添加的方法
     compute: tinaPageOptions.compute || function () {
       return {}
    },
     ...tinaPageOptions.methods,
     // 用于代理所有生命周期(包括 tina 追加的 beforeLoad)
     ...mapObject(pick(tinaPageOptions, PAGE_HOOKS), (handlers) => {
       return function (...args) {
         // 因为做过 mixin 处理,一个生命周期会有多个处理方法
         return handlers.reduce((memory, handler) => {
           const result = handler.apply(this, args.concat(memory))
           return result
        }, void 0)
      }
    }),
     // 以 beforeLoad、onLoad 为例,以上 mapObject 后追加的生命周期处理方法实际执行时是这样的
     // beforeLoad(...args) {
     // return [onLoad1、onLoad2、.....].reduce((memory, handler) => {
     //   return handler.apply(this, args.concat(memory))
     // }, void 0)
     //},
     // onLoad(...args) {
     //   return [onShow1、onShow2、.....].reduce((memory, handler) => {
     //     return handler.apply(this, args.concat(memory))
     //   }, void 0)
     // },
  }

   // tina-page 代理所有属性
   for (let name in members) {
     this[name] = members[name]
  }

   return this
}

首先是将 tinaPageOptions 变成跟 wxPageOptions 一样的结构,因为 wxPageOptions 的 methodshooks 都是在 options 的第一层的,所以需要将将 methods 和 hooks 铺平。
又因为 hooks 经过 mixins 处理已经变成了数组,所以需要遍历执行,每个 hooks 的第二个参数都是之前累积的结果。然后通过简单的属性拷贝将所有方法拷贝到 tina-Page 实例。

改变执行上下文

上面提到构建一个属性跟 wx-Page 一模一样的 tina-Page 对象,那么为什么要这样呢?一个框架的作用是什么?我认为是在原生能力之上建立一个能够提高开发效率的抽象层。现在 tina 就是这个抽象层,
举个例子来说就是我们希望 methods.foo 被原生调用时,tina 能在 methods.foo 里做更多的事情。所以 tina 需要与原生关联使得所有本来由原生处理的东西转交到 tina 这个抽象层处理。
那 tina 是如何处理的呢。我们先来看看创建 wxPageOptions 的源码

// tina/class/page.js
let wxPageOptions = {
 ...wxOptionsGenerator.methods(tinaPageOptions.methods),
 ...wxOptionsGenerator.lifecycles(
   inUseOptionsHooks,
  (name) => ADDON_BEFORE_HOOKS[name]
),
}


// tina/class/page.js
/**
* wxPageOptions.methods 中的改变执行上下文为 tina.Page 对象
* @param {Object} object
* @return {Object}
*/
export function methods(object) {
 return mapObject(object || {}, (method, name) => function handler(...args) {
   let context = this.__tina_instance__
   return context[name].apply(context, args)
})
}

答案就在 wxOptionsGenerator.methods。上面说过在 onLoad 的时候会绑定 __tina_instance__ 到 wx-Page,同时 wx-Page 与 tina-Page 的属性都是一模一样的,所以调用会被转发到 tina 对应的方法。这就相当于 tina 在 wx 之上做了一个抽象层。所有的被动调用都会被 tina 处理。而且因为上下文是 __tina_instance__ 的缘故,
所有主动调用都先经过 tina 再到 wx。结合下面两个小节会有更好的理解。


追加生命周期勾子

上面创建 wxPageOptions 时有这么一句 wxOptionsGenerator.lifecycles 代码,这是 tina 用于在 onLoad 之前加多一个 beforeLoad 生命周期勾子,这个功能是怎么做的呢,我们来看看源码

// tina/utils/wx-options-generator

/**
* options.methods 中的改变执行上下文为 tina.Page 对象
* @param {Array} hooks
* @param {Function} getBeforeHookName
* @return {Object}
*/
export function lifecycles(hooks, getBeforeHookName) {
 return fromPairs(hooks.map((origin) => {
   let before = getBeforeHookName(origin) // 例如 'beforeLoad'
   return [
     origin, // 例如 'load'
     function wxHook() {
       let context = this.__tina_instance__
       // 调用 tina-page 的方法,例如 beforeLoad
       if (before && context[before]) {
         context[before].apply(context, arguments)
      }
       if (context[origin]) {
         return context[origin].apply(context, arguments)
      }
    }
  ]
}))
}

其实就是改写 onLoad ,在调用 tina-Page.onLoad 前先调用 tina-Page.beforeLoad。可能有的人会有疑问,为什么要加个 beforeLoad 勾子,这跟直接 onLoad 里不都一样的么。
举个例子,很多时候我们在 onLoad 拿到 query 之后是不是都要手动去 decode,利用全局 mixinsbeforeLoad,可以一次性把这个事情做了。

Page.mixins = [{
 beforeLoad(query) {
   // 对 query 进行 decode
   // 对 this.$options 进行 decode
}
}]

还有一点需要注意的是,tina 源码中了多次对 onLoad 拦截,执行顺序如下

prependHooks.addHooks.handler -> wx-Page.onLoad,关联 wx-PagetinaPage -> 回到 prependHooks.addHooks.handler -> lifecycles.wxHook -> tina-Page.beforeLoad -> tina-Page.onLoad

如下图所示


compute 实现原理

因为运行时的上下文都被 tina 改为 tina-Page,所以开发者调用的 this.setData, 实际上的 tina-Page 的 setData 方法,又因为 tina-Page 继承自 Basic,也就调用 Basic 的 setData 方法。下面看看 setData 的源码

setData(newer, callback = () => {}) {
 let next = { ...this.data, ...newer }
 if (typeof this.compute === 'function') {
   next = {
     ...next,
     ...this.compute(next),
  }
}
 next = diff(next, this.data)
 this.constructor.log('setData', next)
 if (isEmpty(next)) {
   return callback()
}
 this.$source.setData(next, callback)
}

从源码可以看到就是每次 setData 的时候调用一下 compute 更新数据,这是 compute 的原理,很容易理解吧。

前面 mix 小节提到,tina 会合并一些内置选项,可以看到在 onLoad 时会调用this.setData,为了初始化 compute 属性。

// mixins/index.js

function initial() {
 // 为了初始化 compute 属性
 this.setData()
 this.$log('Initial Mixin', 'Ready')
}

export const $initial = {
 // ...
 onLoad: initial,// 页面加载完成勾子
}

小结

到此基本上把 Page.define 主干流程讲完,如有疑问欢迎留言

参考

来源:segmentfault.com/a/1190000021949561

收起阅读 »

GitHub:全国各省市烂尾楼停贷汇总

最近频繁收到关于“停贷”的新闻推送,疫情这几年对经济影响确实大。年轻人前有老板压榨,后有房贷鞭挞。气愤前同事弃坑跑路,却又不得不接手。面对“屎山”代码,心里很排斥却还担心被人抢走,因为身边的同事一批批的毕业,但你不想毕业,自从有了妻子、有了孩子、有了房贷,你变...
继续阅读 »

最近频繁收到关于“停贷”的新闻推送,疫情这几年对经济影响确实大。

年轻人前有老板压榨,后有房贷鞭挞。

气愤前同事弃坑跑路,却又不得不接手。

面对“屎山”代码,心里很排斥却还担心被人抢走,因为身边的同事一批批的毕业,

但你不想毕业,

自从有了妻子、有了孩子、有了房贷,

你变得更有责任心了。

你不会再因为一时冲动离职。

你变得脾气好了,

更能适应领导的加班安排、更能接受遇到的不公平。

可是最后,

你还是毕业了……

你不停的找朋友内推,

又计算着自己可以维持多久的房贷。

直到业主群里炸锅:楼盘烂尾、房开跑路了!

你没有生气,

反而异常平静。

扔掉了房贷计算的稿纸,

习惯性的打开GitHub,

鬼使神差的输入“烂尾楼”

竟然发现一个项目:全国各省市烂尾楼停贷通知汇总(微信打不开要用浏览器https://github.com/WeNeedHome/SummaryOfLoanSuspension)


 一天更新40+,快去看看有没有你家附近的吧!


不知道这个项目会不会像996ICU那样受关注。目前star已经13k了,太疯狂了,我辛辛苦苦写个开源项目,一年下来才二百来star。虽然技术无关,但也算技术圈的网红了。

逛了一圈,很满足,仿佛我又是一个纯粹的技术人。

看着窗外远远的星星,一颗、两颗、无数颗,却没有一颗属于我,正如这灯火通明的城市,没有一处灯是属于我的,我头上的灯是房东的。

我想我买的小区此刻肯定漆黑一片,因为都没建好,都烂尾了,开发商都跑路了。

我如梦初醒,我他妈工作没了,房子没了,还有心情在这逛GitHub,

我真是一个失败的码农,逛GitHub还分心!

收起阅读 »

慢 SQL 分析与优化

背景介绍从系统设计角度看,一个系统从设计搭建到数据逐步增长,SQL 执行效率可能会出现劣化,为继续支撑业务发展,我们需要对慢 SQL 进行分析和优化,严峻的情况下甚至需要对整个系统进行重构。所以我们往往需要在系统设计前对业务进行充分调研、遵守系统设计规范,在系...
继续阅读 »

背景介绍

从系统设计角度看,一个系统从设计搭建到数据逐步增长,SQL 执行效率可能会出现劣化,为继续支撑业务发展,我们需要对慢 SQL 进行分析和优化,严峻的情况下甚至需要对整个系统进行重构。所以我们往往需要在系统设计前对业务进行充分调研、遵守系统设计规范,在系统运行时定期结合当前业务发展情况进行系统瓶颈的分析。

从数据库角度看,每个 SQL 执行都需要消耗一定 I/O 资源,SQL 执行的快慢,决定了资源被占用时间的长短。假如有一条慢 SQL 占用了 30%的资源共计 1 分钟。那么在这 1 分钟时间内,其他 SQL 能够分配的资源总量就是 70%,如此循环,当资源分配完的时候,所有新的 SQL 执行将会排队等待。所以往往一条慢 SQL 会影响到整个业务。

本文仅讨论 MySQL-InnoDB 的情况。

优化方式

SQL 语句执行效率的主要因素

  • 数据量

    • SQL 执行后返回给客户端的数据量的大小;

    • 数据量越大需要扫描的 I/O 次数越多,数据库服务器的 IO 更容易成为瓶颈。

  • 取数据的方式

    • 数据在缓存中还是在磁盘上;

    • 是否能够通过全局索引快速寻址;

    • 是否结合谓词条件命中全局索引加速扫描。

  • 数据加工的方式

    • 排序、子查询、聚合、关联等,一般需要先把数据取到临时表中,再对数据进行加工;

    • 对于数据量比较多的计算,会消耗大量计算节点的 CPU 资源,让数据加工变得更加缓慢;

    • 是否选择了合适的 join 方式

优化思路

  • 减少数据扫描(减少磁盘访问)

    • 尽量在查询中加入一些可以提前过滤数据的谓词条件,比如按照时间过滤数据等,可以减少数据的扫描量,对查询更友好;

    • 在扫描大表数据时是否可以命中索引,减少回表代价,避免全表扫描。

  • 返回更少数据(减少网络传输或磁盘访问)

  • 减少交互次数(减少网络传输)

    • 将数据存放在更快的地方

    • 某条查询涉及到大表,无法进一步优化,如果返回的数据量不大且变化频率不高但访问频率很高,此时应该考虑将返回的数据放在应用端的缓存当中或者 Redis 这样的缓存当中,以提高存取速度。

  • 减少服务器 CPU 开销(减少 CPU 及内存开销)

  • 避免大事务操作

  • 利用更多资源(增加资源)

优化案例

数据分页优化

sele ct * from table_demo where type = ? limit ?,?;

优化方式一:偏移 id

lastId = 0 or min(id)
do {
sele ct * from table_demo where type = ? and id >{#lastId} limit ?;
lastId = max(id)
} while (isNotEmpty)

优化方式二:分段查询

该方式较方式一的优点在于可并行查询,每个分段查询互不依赖;较方式一的缺点在于较依赖数据的连续性,若数据过于分散,代价较高。

minId = min(id) maxId = max(id)
for(int i = minId; i<= maxId; i+=pageSize){
sele ct * from table_demo where type = ? and id between i and i+ pageSize;
}

优化 GROU P BY

提高 GROU P BY 语句的效率, 可以通过将不需要的记录在 GROU P BY 之前过滤掉.下面两个查询返回相同结果但第二个明显就快了许多。

低效:

sele ct job , avg(sal) from table_demo grou p by job having job = ‘manager'

高效:

sele ct job , avg(sal) from table_demo where job = ‘manager' grou p by job

范围查询

联合索引中如果有某个列存在范围(大于小于)查询,其右边的列是否还有意义?

expla in sele ct count(1) from statement where org_code='1012' and trade_date_time >= '2019-05-01 00:00:00' and trade_date_time<='2020-05-01 00:00:00'
expla in sele ct * from statement where org_code='1012' and trade_date_time >= '2019-05-01 00:00:00' and trade_date_time<='2020-05-01 00:00:00' limit 0, 100
expla in sele ct * from statement where org_code='1012' and trade_date_time >= '2019-05-01 00:00:00' and trade_date_time<='2020-05-01 00:00:00'
  • 使用单键索引 trade_date_time 的情况下

    • 从索引里找到所有 trade_date_time 在'2019-05-01' 到'2020-05-01' 区间的主键 id。假设有 100 万个。

    • 对这些 id 进行排序(为的是在下面一步回表操作中优化 I/O 操作,因为很多挨得近的主键可能一次磁盘 I/O 就都取到了)

    • 回表,查出 100 万行记录,然后逐个扫描,筛选出 org_code='1020'的行记录

  • 使用联合索引 trade_date_time, org_code -联合索引 trade_date_time, org_code 底层结构推导如下:


以查找 trade_date_time >='2019-05-01' and trade_date_time <='2020-05-01' and org_code='1020'为例:

  1. 在范围查找的时候,直接找到最大,最小的值,然后进行链表遍历,故仅能用到 trade_date_time 的索引,无法使用到 org_code 索引

  2. 基于 MySQL5.6+的索引下推特性,虽然 org_code 字段无法使用到索引树,但是可以用于过滤回表的主键 id 数。

小结:对于该 case, 索引效果[org_code,trade_date_time] > [trade_date_time, org_code]>[trade_date_time]。实际业务场景中,检索条件中 trade_date_time 基本上肯定会出现,但 org_code 却不一定,故索引的设计还需要结合实际业务需求。

优化 Order by

索引:

KEY `idx_account_trade_date_time` (`account_number`,`trade_date_time`),
KEY `idx_trade_date_times` (`trade_date_time`)
KEY `idx_createtime` (`create_time`),

慢 SQL:

SELE CT id,....,creator,modifier,create_time,update_time FROM statement
WHERE (account_number = 'XXX' AND create_time >= '2022-04-24 06:03:44' AND create_time <= '2022-04-24 08:03:44' AND dc_flag = 'C') ORDER BY trade_date_time DESC,id DESC LIMIT 0,1000;

优化前:SQL 执行超时被 kill 了

SELE CT id,....,creator,modifier,create_time,upda te_time FROM statement
WHERE (account_number = 'XXX' AND create_time >= '2022-04-24 06:03:44' AND create_time <= '2022-04-24 08:03:44' AND dc_flag = 'C') ORDER BY create_time DESC,id DESC LIMIT 0,1000;

优化后:执行总行数为:6 行,耗时 34ms。

MySQL使不使用索引与所查列无关,只与索引本身,where条件,order by 字段,grou p by 字段有关。索引的作用一个是查找,一个是排序。

业务拆分

sele ct * from order where status='S' and update_time < now-5min limit 500

拆分优化:

随着业务数据的增长 status='S'的数据基本占据数据的 90%以上,此时该条件无法走索引。我们可以结合业务特征,对数据获取按日期进行拆分。

date = now; minDate = now - 10 days
while(date > minDate) {
sele ct * from order where order_date={#date} and status='S' and upda te_time < now-5min limit 500
date = data + 1
}

数据库结构优化

  1. 范式优化:表的设计合理化(符合 3NF),比如消除冗余(节省空间);

  2. 反范式优化:比如适当加冗余等(减少 join)

  3. 拆分表:分区将数据在物理上分隔开,不同分区的数据可以制定保存在处于不同磁盘上的数据文件里。这样,当对这个表进行查询时,只需要在表分区中进行扫描,而不必进行全表扫描,明显缩短了查询时间,另外处于不同磁盘的分区也将对这个表的数据传输分散在不同的磁盘 I/O,一个精心设置的分区可以将数据传输对磁盘 I/O 竞争均匀地分散开。对数据量大的表可采取此方法,可按月建表分区。

SQL 语句优化

SQL 检查状态及分数计算逻辑

  1. 尽量避免使用子查询

  2. 用 IN 来替换 OR

  3. 读取适当的记录 LIMIT M,N,而不要读多余的记录

  4. 禁止不必要的 Order By 排序

  5. 总和查询可以禁止排重用 union all

  6. 避免随机取记录

  7. 将多次插入换成批量 Insert 插入

  8. 只返回必要的列,用具体的字段列表代替 sele ct * 语句

  9. 区分 in 和 exists

  10. 优化 Grou p By 语句

  11. 尽量使用数字型字段

  12. 优化 Join 语句

大表优化

  • 分库分表(水平、垂直)

  • 读写分离

  • 数据定期归档

原理剖析

MySQL 逻辑架构图:


索引的优缺点

优点

  • 提高查询语句的执行效率,减少 IO 操作的次数

  • 创建唯一性索引,可以保证数据库表中每一行数据的唯一性

  • 加了索引的列会进行排序,在使用分组和排序子句进行查询时,可以显著减少查询中分组和排序的时间

缺点

  • 索引需要占物理空间

  • 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加

  • 当对表中的数据进行增删改查时,索引也要动态的维护,这样就降低了数据的更新效率

索引的数据结构

主键索引


普通索引


组合索引


索引页结构


索引页由七部分组成,其中 Infimum 和 Supremum 也属于记录,只不过是虚拟记录,这里为了与用户记录区分开,还是决定将两者拆开。


数据行格式:

MySQL 有 4 种存储格式:

  1. Compact

  2. Redundant (5.0 版本以前用,已废弃)

  3. Dynamic (MySQL5.7 默认格式)

  4. Compressed


Dynamic 行存储格式下,对于处理行溢出(当一个字段存储长度过大时,会发生行溢出)时,仅存放溢出页内存地址。

索引的设计原则

哪些情况适合建索引

  • 数据又数值有唯一性的限制

  • 频繁作为 where 条件的字段

  • 经常使用 grou p by 和 order by 的字段,既有 gro up by 又有 order by 的字段时,建议建联合索引

  • 经常作为 upda te 或 dele te 条件的字段

  • 经常需要 distinct 的字段

  • 多表连接时的字段建议创建索引,也有注意事项

    • 连接表数量最好不要超过 3 张,每增加一张表就相当于增加了一次嵌套循环,数量级增长会非常快

    • 对多表查询时的 where 条件创建索引

    • 对连接字段创建索引,并且数据类型保持一致

  • 在确定数据范围的情况下尽量使用数据类型较小的,因为索引会也会占用空间

  • 对字符串创建索引时建议使用字符串的前缀作为索引

  • 这样做的好处是:

    • 能节省索引的空间,

    • 虽然不能精确定位,但是能够定位到相同的前缀,然后通过主键查询完整的字符串,这样既能节省空间,又减少了字符串的比较时间,还能解决排序问题。

  • 区分度高(散列性高)的字段适合作为索引。

  • 在多个字段需要创建索引的情况下,联合索引优先于单值索引。使用最频繁的列作为索引的最左侧 。

哪些情况下不需要使用索引

  • 在 where 条件中用不到的字段不需要。

  • 数据量小的不需要建索引,比如数据少于 1000 条。

  • 由大量重复数据的列上不要建索引,比如性别字段中只有男和女时。

  • 避免在经常更新的表或字段中创建过多的索引。

  • 不建议主键使用无序的值作为索引,比如 uuid。

  • 不要定义冗余或重复的索引

  • 例如:已经创建了联合索引 key(id,name)后就不需要再单独建一个 key(id)的索引

索引优化之 MRR

例如有一张表 user,主键 id,普通字段 age,为 age 创建非聚集索引,有一条查询语句 sele ct* user from table where age > 18;(注意查询语句中的结果是*)

在 MySQL5.5 以及之前的版本中如何查询呢?先通过非聚集索引查询到 age>18 的第一条数据,获取到了主键 id;然后根据非聚集索引中的叶子节点存储的主键 id 去聚集索引中查询行数据;根据 age>18 的数据条数每次查询聚集索引,这个过程叫做回表。

上述的步骤有什么缺点呢?如何 age>18 的数据非常多,那么每次回表都需要经过 3 次 IO(假设 B+树的高度是 3),那么会导致查询效率过低。

在 MySQL5.6 时针对上述问题进行了优化,优化器先查询到 age>3 的所有数据的主键 id,对所有主键的 id 进行排序,排序的结果缓存到 read_rnd_buffer,然后通过排好序的主键在聚簇索引中进行查询。

如果两个主键的范围相近,在同一个数据页中就可以之间按照顺序获取,那么磁盘 io 的过程将会大大降低。这个优化的过程就叫做 Multi Range Read(MRR) 多返回查询。

索引下推

假设有索引(name, age), 执行 SQL: sele ct * from tuser where name like '张%' and age=10;


MySQL 5.6 以后, 存储引擎根据(name,age)联合索引,找到,由于联合索引中包含列,所以存储引擎直接在联合索引里按照age=10过滤。按照过滤后的数据再一一进行回表扫描。


索引下推使用条件

  • 只能用于rangerefeq_refref_or_null访问方法;

  • 只能用于InnoDBMyISAM存储引擎及其分区表;

  • 对存储引擎来说,索引下推只适用于二级索引(也叫辅助索引);

索引下推的目的是为了减少回表次数,也就是要减少 IO 操作。对于的聚簇索引来说,数据和索引是在一起的,不存在回表这一说。

  • 引用了子查询的条件不能下推;

  • 引用了存储函数的条件不能下推,因为存储引擎无法调用存储函数。

思考:

  1. MySQL 一张表到底能存多少数据?

  2. 为什么要控制单行数据大小?

  3. 优化案例 4 中优化前的 SQL 为什么走不到索引?

总结

抛开数据库硬件层面,数据库表设计、索引设计、业务代码逻辑、分库分表策略、数据归档策略都对 SQL 执行效率有影响,我们只有在整个设计、开发、运维阶段保持高度敏感、追求极致,才能让我们系统的可用性、伸缩性不会随着业务增长而劣化。

参考资料

  1. https://help.aliyun.com/document_detail/311122.html

  2. https://blog.csdn.net/qq_32099833/article/details/123150701

  3. https://www.cnblogs.com/tufujie/p/9413852.html

来源:字节跳动技术团队

收起阅读 »

面试官:应用上线后Cpu使用率飙升如何排查?

大家好,我是飘渺。 上次面试官问了个问题:应用上线后Cpu使用率飙升如何排查? 其实这是个很常见的问题,也非常简单,那既然如此我为什么还要写呢?因为上次回答的时候我忘记将线程PID转换成16进制的命令了。 所以我决定再重温一遍这个问题,当然贴心的我还给大家准备...
继续阅读 »

大家好,我是飘渺。


上次面试官问了个问题:应用上线后Cpu使用率飙升如何排查?


其实这是个很常见的问题,也非常简单,那既然如此我为什么还要写呢?因为上次回答的时候我忘记将线程PID转换成16进制的命令了。


所以我决定再重温一遍这个问题,当然贴心的我还给大家准备好了测试代码,大家可以实际操作一下,这样下次就不会忘记了。


模拟一个高CPU场景


public class HighCpuTest {
public static void main(String[] args) {
List<HignCpu> cpus = new ArrayList<>();

Thread highCpuThread = new Thread(()->{
int i = 0;
while (true){
HignCpu cpu = new HignCpu("Java日知录",i);

cpus.add(cpu);
System.out.println("high cpu size:" + cpus.size());
i ++;
}
});
highCpuThread.setName("HignCpu");
highCpuThread.start();
}
}

在main方法中开启了一个线程,无限构建HighCpu对象。


@Data
@AllArgsConstructor
public class HignCpu {
private String name;
private int age;
}

准备好上面的代码,运行HighCpuTest,然后就可以开始一些列的操作来发现问题原因了。


排查步骤


第一步,使用 top 找到占用 CPU 最高的 Java 进程


1. 监控cpu运行状,显示进程运行信息列表
top -c

2. 按CPU使用率排序,键入大写的P
P

image-20220627165915946


第二步,用 top -Hp 命令查看占用 CPU 最高的线程


上一步用 top命令找到了那个 Java 进程。那一个进程中有那么多线程,不可能所有线程都一直占着 CPU 不放,这一步要做的就是揪出这个罪魁祸首,当然有可能不止一个。


执行top -Hp pid命令,pid 就是前面的 Java 进程,我这个例子中就是 16738 ,完整命令为:


top -Hp 16738,然后键入P (大写p),线程按照CPU使用率排序


执行之后的效果如下


image-20220627165953456


查到占用CPU最高的那个线程 PID 为 16756


第三步,查看堆栈信息,定位对应代码


通过printf命令将其转化成16进制,之所以需要转化为16进制,是因为堆栈里,线程id是用16进制表示的。(我当时就是忘记这个命令了~)


[root@review-dev ~]# printf "%x\n" 16756
4174

得到16进制的线程ID为4174。


通过jstack命令查看堆栈信息


jstack 16738 | grep '0x4174' -C10 --color

image-20220627170218909


如上图,找到了耗CPU高的线程对应的线程名称“HighCpu”,以及看到了该线程正在执行代码的堆栈。


最后,根据堆栈里的信息,定位到对应死循环代码,搞定。


小结


cpu使用率飙升后如何排查这个问题不仅面试中经常会问,而且在实际工作中也非常有用,大家最好根据上述步骤实际操作一下,这样才能记得住记得牢。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿


作者:飘渺Jam
链接:https://juejin.cn/post/7119116752939646984
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

基于环信IM iOS Demo 重构messageCell方案

本文章相关的视频教程:https://www.imgeek.org/video/108Demo下载地址:https://gitee.com/huanxin666/EMDemo-oc----------------------------------------...
继续阅读 »

本文章相关的视频教程:

https://www.imgeek.org/video/108

Demo下载地址:

https://gitee.com/huanxin666/EMDemo-oc

-----------------------------------------------------------


1.messageCell是指哪一块儿?

messageCell是用来展示消息内容的item.
界面效果:

1.单聊


2.聊天室


代码:
原版采用构造对应气泡来实现 (EaseMessageCell.m)




改造后每种消息都将会使用新的cell单元格



2.为什么要重构messageCell?

当前实现方案:控制气泡显示内容,如此不利于我们对cell进行界面显示调整,并且当前使用的cell高度为自动计算高度,如此不方便我们计算当前滚动视图的高度,无法实现下拉刷新.
总结为两点:
a.更加方便对其显示效果做定制化需求.
b.解决下拉加载更多消息会直接顶到顶部的问题.(已录制视频)

3.我们应该怎么进行重构?

当前展示数据的逻辑:
拿到一组消息
将消息转为更加便于展示在界面上的模型
将模型给到item展示出来

messageList -> messageCellModelList -> UITableView展示

我们依然采用此逻辑,仅做界面调整,以及增加计算cell高度.

我们需要做两件事:
messageCellModel进行改造
这里,我直接创建了一个viewModel继承自EaseMessageModel




内部的核心两点:
1 构建消息时,将item的cellname做下存储,用cellname来判定我们将使用哪一个cell (identifier)
2 cell的高度计算(其中包含文字/边距等所有占用高度相加)
为了统一边距等值,我们可以将这些值做下整理:





我们也可以加入展示与隐藏昵称头像功能,使展示效果更加灵活.




另补充:
在这里,还进行了部分优化,例如:原版的messageModel数组理应存所有messageModel,而不应该存字符串(这么做的原因是加入时间显示)
优化之后将不再使用字符串,也使用model来做表示.代码对比如下:




对messageCell进行重构

布局,这里使用的Masonry布局
布局需要注意:当前使用Masonry布局,但不会使用自动计算高度方式,所以不能将纵向高度全给上,只需要其中一个高度不给即可.



其他方面:
交互对接



另附:
我这边采用的布局以及继承关系
布局方面:

聊天界面所有的cell顶级父类:
EMsgBaseCell : UITableViewCell


用户消息cell的父类
EMsgUserBaseCell : EMsgBaseCell

其他展示用户消息的cell,例如展示文字:
EMsgUserTextCell : EMsgUserBaseCell

特殊cell (非用户消息展示)
展示时间,直接继承于顶级父类
EMsgTimeMarkerCell : EMsgBaseCell

展示系统提醒,直接继承于顶级父类
EMsgSystemRemindCell : EMsgBaseCell

类继承关系图:



布局方面:



项目中视图体现如⬇️⬇️两张图:




收起阅读 »

Android使用Intent传递大数据

数据传输 在Android开发过程中,我们常常通过Intent在各个组件之间传递数据。例如在使用startActivity(android.content.Intent)方法启动新的 Activity 时,我们就可以通过创建Intent对象然后调用putExt...
继续阅读 »

数据传输


在Android开发过程中,我们常常通过Intent在各个组件之间传递数据。例如在使用startActivity(android.content.Intent)方法启动新的 Activity 时,我们就可以通过创建Intent对象然后调用putExtra() 方法传输参数。


val intent = Intent(this, TestActivity::class.java)
intent.putExtra("name","name")
startActivity(intent)

启动完新的Activity之后,我们可以在新的Activity获取传输的数据。


val name = getIntent().getStringExtra("name")

一般情况下,我们传递的数据都是很小的数据,但是有时候我们想传输一个大对象,比如bitmap,就有可能出现问题。


val intent = Intent(this, TestActivity::class.java)
val data= ByteArray( 1024 * 1024)
intent.putExtra("param",data)
startActivity(intent)

当调用该方法启动新的Activity的时候就会抛出异常。


android.os.TransactionTooLargeException: data parcel size 1048920 bytes

很明显,出错的原因是我们传输的数据量太大了。在官方文档中有这样的描述:



The Binder transaction buffer has a limited fixed size, currently 1Mb, which is shared by all transactions in progress for the process. Consequently this exception can be thrown when there are many transactions in progress even when most of the individual transactions are of moderate size。



即缓冲区最大1MB,并且这是该进程中所有正在进行中的传输对象所公用的。所以我们能传输的数据大小实际上应该比1M要小。


替代方案



  1. 我们可以通过静态变量来共享数据

  2. 使用bundle.putBinder()方法完成大数据传递。
    由于我们要将数据存放在Binder里面,所以先创建一个类继承自Binder。data就是我们传递的数据对象。


class BigBinder(val data:ByteArray):Binder()

然后传递


val intent = Intent(this, TestActivity::class.java)
val data= ByteArray( 1024 * 1024)
val bundle = Bundle()
val bigData = BigBinder(data)
bundle.putBinder("bigData",bigData)
intent.putExtra("bundle",bundle)
startActivity(intent)

然后正常启动新界面,发现可以跳转过去,而且新界面也可以接收到我们传递的数据。


为什么通过这种方式就可以绕过1M的缓冲区限制呢,这是因为直接通过Intent传递的时候,系统采用的是拷贝到缓冲区的方式,而通过putBinder的方式则是利用共享内存,而共享内存的限制远远大于1M,所以不会出现异常。


作者:晚来天欲雪_
链接:https://juejin.cn/post/7109862936604049415
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

货拉拉 Android H5离线包原理与实践

背景 在实际业务中,app中的H5页面使用的场景越来越多,在货拉拉app中也存在大量的H5页面,比如金秋拉货节、余额、车型介绍页等,加载速度成为了困扰用户的一个痛点。为此我们决定引入离线包方案,另外还需要解决传统离线包方案不灵活,体积大,不易管理,不易降级...
继续阅读 »
  1. 背景




在实际业务中,app中的H5页面使用的场景越来越多,在货拉拉app中也存在大量的H5页面,比如金秋拉货节、余额、车型介绍页等,加载速度成为了困扰用户的一个痛点。为此我们决定引入离线包方案,另外还需要解决传统离线包方案不灵活,体积大,不易管理,不易降级等问题,我们设计和开发一套H5离线包系统,经过几个sdk版本的迭代,目前货拉拉H5离线包sdk,已在多个业务中落地,接受了大量用户检验。车型介绍页面使用离线包前后打开效果:






  1. 行业方案




目前H5离线包方案,通常是将离线包置入assets目录中,打包在apk内部,用户使用过程中再按需加载。所以大部分情况下可能存在以下问题:



  1. 由于离线包内容固定导致更新不及时

  2. 当离线包内容较多或者离线包个数较多时,会严重影响App包体积

  3. 由于离线包内部的逻辑固定,当出现问题时无法降级,无法禁用

  4. 上线没有数据对比无法知道上线效果


针对以上痛点,我们团队对离线包进行设计优化,应用于团队内的多个应用,多个业务场景中。




  1. 技术实现




H5离线包的基本原理是将html、js、css、图片等静态资源打包到成压缩文件,然后下载到客户端,H5加载时静态资源直接从本地取文件,减少网络请求,提高速度。加载本地文件路径存在的问题和解决:



























存在问题解决方法
cgi请求跨域跨域请求头增加null支持
cookie跨域问题目前静态js中无cookie操作,没有cookie跨域问题
localstorage跨域问题暂时不涉及域名隔离问题,如果有需要,采取调用原生的方式解决
前端使用绝对路径问题相对路径

4.1 总体结构


H5发布基本流程


image.png


App端流程图


image.png


前端的打包平台,支持发布为线上页面,也支持发布为离线包。离线包模式时,客户端会先查询是否有离线包需要更新,有则更新,同时支持离线包降级为线上网页。


H5离线包和线上H5一样也能进行更新和升级,有三个更新时机:


1)WebView容器打开时更新。在需要开启离线包功能的H5页面打开时,会去后端检查对应的离线包页面是否有更新。如果有更新,则下载离线包到本地,绝大部分场景是下次打开时生效。


2)启动查询离线包更新。对于实时性要求比较高的页面,可配置在启动时检查更新。


3)通过长连接推送的方式通知客户端下载最新的离线包。(需要接入方自己实现长链接,调用SDK更新方法)


4.2 性能优化


1)多业务并行化,单业务串行


离线包检查更新时,存在同时查询多个业务的离线包是否有更新的情况,为了提高查询效率,多个业务离线包检查的请求采取并行请求的方式。考虑到后端改造成本问题,目前还不支持聚合查询,计划在后续版本中完善。另外,考虑业务流程的更新流程取消可能导致不稳定,单业务只做串行,避免过程中文件损坏,下载不全,线程并发的问题。


image.png


2)启动预下载


大部分离线包查询和下载的时机为打开H5页面时,由于离线包查询、下载、解压总体耗时较长,导致首次打开无法命中离线包。所以货拉拉离线包支持配置部分离线包在启动时检查和下载离线包。配置为:


OfflineConfig offlineConfig = new OfflineConfig.Builder(true)

.addPreDownload("offline-pkg-name")//预加载业务名称

.build();,

4.3 可靠性设计


1)解压操作可靠性设计


文件解压耗时较长(大约30ms),如果中间程序退出可能会导致只解压了其中一半文件,影响后续离线包逻辑。所以解压到文件夹操作采取先解压,然后重命名,保证最后的文件夹的里的文件是完整的。同时当离线包正在使用时,一般情况下采取先解压,下次生效的策略,极端情况下可以立刻生效,但会导致页面强刷,影响用户体验。操作过程采取了temp、new、cur三个文件夹,解压细节如下


image.png


2)三重降级策略


a.客户端自动降级。


本地没有离线包时,客户端会自动将启用了离线包的H5页面降级为线上H5页面。


b.客户端远程配置降级。


可以设置局部降级,即临时将某个使用离线包的H5页面降级为线上,也可设置全局降级,关闭所有页面的离线包功能。接入方可以自行根据自己服务端下发参数进行配置:


OfflineConfig offlineConfig = new OfflineConfig.Builder(true)//总开关

.addDisable("disable-offline-pkg-name")//禁用业务名称

.addPreDownload("offline-pkg-name")//预加载业务名称

.build();

c.服务端接口降级。


服务端提供的离线包查询接口也可以设置将某个页面降级为线上H5,也可以支持让客户端更新离线包后强制刷新。目前,强制刷新为空实现,需要接入方自己实现,例如重启当前页面,关闭当前页面等。


降级策略流程图如下:


image.png


3)性能监控


货拉拉对webview的加载成功率,错误码、耗时进行了统计上报,通过监控面板查看。



此外离线包sdk还有离线包下载,请求,解压的耗时、结果数据上报。监控和上报采取的接口扩展方式,接入方根据业务特点选用具体的数据上报sdk。


4.4 效能优化


离线包和URL映射配置化


image.png


配置格式如下:主要通过url中的host、path、Fragment配置命中规则。根据接入方是否需要传入,不需要可以不传递。


//匹配规则相关 可选

ArrayList<String> host = new ArrayList<>();

ArrayList<String> path = new ArrayList<>();

ArrayList<String> fragment = new ArrayList<>();

host.add("www.xxxx.cn");

path.add("/aaa");

fragment.add("/ccc=ddd");



OfflineRuleConfig offlineRuleConfig = new OfflineRuleConfig();

offlineRuleConfig.addRule(new OfflineRuleConfig.RulesInfo("offline-pkg-name",host,path,fragment));


new OfflineParams()

.addRule("offline-pkg-name",host,path,fragment)//自定义配置的形式

.setRule(Constants.RULE_CONFIG)//json形式的规则

.setRule(offlineRuleConfig)//实体类形式

{
"rules": [{
"host": ["test1.xxx.cn", "test2.xxx.cn"],
"path": ["/pathA"],
"offweb": "offline-pkg-name-a"
},
{
"host": ["www.aaa.cn", "aaa.xxxx.cn"],
"path": ["aaa/path", "bbb/path"],
"offweb": "offline-pkg-name-b"
}
]
}



  1. 总结




离线包上线后,收益明显,平均加载速度从2秒提升到1秒,同时H5页面加载成功率也有提升。页面主框架(不考虑动态数据)加载成功率从96%提升到100%。






  1. 后期工作与展望




扩大开源范围。比如支持断点续传的下载SDK,后续会考虑开源。离线包依赖的后端服务暂时未开源,目前采取是通过HttpServer搭建一个简单的本地Web Server,可保证离线包示例在本地正常运行。


具体使用方法参考开源代码中介绍(github.com/HuolalaTech…




  1. 参考资料




zhuanlan.zhihu.com/p/34125968


juejin.cn/post/684490…




  1. 作者介绍




货拉拉移动端技术团队


作者:货拉拉移动技术团队
链接:https://juejin.cn/post/7119662876578545678
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter 绘制探索 | 来一起画箭头吧

0. 前言 可能有人会觉得,画箭头有什么好说的,不就一根线加两个头吗?其实箭头的绘制还是比较复杂的,其中也蕴含着很多绘制的小技巧。箭头本身有着很强的 示意功能 ,通常用于指示、标注、连接。各种不同的箭头端,再加上线型的不同,可以组合成一些固定连接语法,比如 U...
继续阅读 »
0. 前言

可能有人会觉得,画箭头有什么好说的,不就一根线加两个头吗?其实箭头的绘制还是比较复杂的,其中也蕴含着很多绘制的小技巧。箭头本身有着很强的 示意功能 ,通常用于指示、标注、连接。各种不同的箭头端,再加上线型的不同,可以组合成一些固定连接语法,比如 UML 中的类图。





一个箭头,其核心数据是两个点的坐标,由 左右端点线型 构成。这篇文章就来探索一下,如何绘制一个支持各种样式,而且容易拓展的箭头。





1. 箭头部位的划分

首先要说一点,我希望获取的是箭头的 路径 ,而非单纯的绘制箭头。因为有了路径,可以做更多的事,比如根据路径裁剪、沿路径运动、多个路径间的合并操作等。当然,路径形成之后,绘制自然是非常简单的。所以在绘制技巧中,路径一个非常重要的话题。

如下所示,我们先来生成三个部分的路径,并进行绘制,两端暂时是圆形路径:



代码实现如下,测试使用的起始点分别是 (40,40)(200,40),圆形路径以起始点为中心,宽高为 10。可以看出虽然实现了需求,但是都写在一块,代码看起来比较乱。当要涉及生成各种样式箭头时,在这里修改代码也是非常麻烦的,接下来要做的就是对箭头的路径形成过程进行抽象。


final Paint arrowPainter = Paint();

Offset p0 = Offset(40, 40);
Offset p1 = Offset(200, 40);
double width = 10;
double height = 10;

Rect startZone = Rect.fromCenter(center: p0, width: width, height: height);
Path startPath = Path()..addOval(startZone);
Rect endZone = Rect.fromCenter(center: p1, width: width, height: height);
Path endPath = Path()..addOval(endZone);

Path linePath = Path()..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy);

arrowPainter
..style = PaintingStyle.stroke..strokeWidth = 1
..color = Colors.red;

canvas.drawPath(startPath, arrowPainter);
canvas.drawPath(endPath, arrowPainter);
canvas.drawPath(linePath, arrowPainter);



如下,定义抽象类 AbstractPathformPath 抽象出来,交由子类实现。端点的路径衍生出 PortPath 进行实现,这就可以将一些重复的逻辑进行封装,也有利于维护和拓展。整体路径的生成由 ArrowPath 类负责:


abstract class AbstractPath{
Path formPath();
}

class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
path.addOval(zone);
return path;
}
}

class ArrowPath extends AbstractPath{
final PortPath head;
final PortPath tail;

ArrowPath({required this.head,required this.tail});

@override
Path formPath() {
Offset line = (tail.position - head.position);
Offset center = head.position+line/2;
double length = line.distance;
Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
Path linePath = Path()..addRect(lineZone);
Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
return Path.combine(PathOperation.union, temp, tail.formPath());
}
}



这样,矩形域的确定和路径的生成,交由具体的类进行实现,在使用时就会方便很多:


double width =10;
double height =10;
Size portSize = Size(width, height);
ArrowPath arrow = ArrowPath(
head: PortPath(p0, portSize),
tail: PortPath(p1, portSize),
);
canvas.drawPath(arrow.formPath(), arrowPainter);




2. 关于路径的变换

上面我们的直线其实是矩形路径,这样就会出现一些问题,比如当箭头不是水平线,会出现如下问题:



解决方案也很简单,只要让矩形直线的路径沿两点的中心进行旋转即可,旋转的角度就是两点与水平线的夹角。这就涉及了绘制中非常重要的技巧:矩阵变换 。如下代码添加的四行 Matrix4 的操作,就可以通过矩阵变换,让 linePathcenter 为中心旋转两点间角度。这里注意一下,tag1 处的平移是为了将变换中心变为 center、而tag2 处的反向平移是为了抵消 tag1 平移的影响。这样在两者之间的变换,就是以 center 为中心的变换:


class ArrowPath extends AbstractPath{
final PortPath head;
final PortPath tail;

ArrowPath({required this.head,required this.tail});

@override
Path formPath() {
Offset line = (tail.position - head.position);
Offset center = head.position+line/2;
double length = line.distance;
Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
Path linePath = Path()..addRect(lineZone);

// 通过矩阵变换,让 linePath 以 center 为中心旋转 两点间角度
Matrix4 lineM4 = Matrix4.translationValues(center.dx, center.dy, 0); // tag1
lineM4.multiply(Matrix4.rotationZ(line.direction));
lineM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0)); // tag2
linePath = linePath.transform(lineM4.storage);

Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
return Path.combine(PathOperation.union, temp, tail.formPath());
}
}

这样就一切正常了,可能有人会疑惑,为什么不直接用两点形成路径呢?这样就不需要旋转了:



前面说了,这里希望获得的是一个 箭头路径 ,使用线型模式就可以看处用矩形的妙处。如果单纯用路径的移动来处理,需要计算点位,比较复杂。而用矩形加旋转,就方便很多:





3.尺寸的矫正

可以看出,目前是以起止点为圆心的矩形区域,但实际我们需要让箭头的两端顶点在两点上。有两种解决方案:其一,在 PortPath 生成路径时,对矩形区域中心进行校正;其二,在合成路径前通过偏移对首位断点进行校正。



我更倾向于后者,因为我希望 PortPath 只负责断点路径的生成,不需要管其他的事。另外 PortPath 本身也不知道端点是起点还是终点,因为起点需要沿线的方向偏移,终点需要沿反方向偏移。处理后效果如下:



---->[ArrowPath#formPath]----
Path headPath = head.formPath();
Matrix4 headM4 = Matrix4.translationValues(head.size.width/2, 0, 0);
headPath = headPath.transform(headM4.storage);

Path tailPath = tail.formPath();
Matrix4 tailM4 = Matrix4.translationValues(-head.size.width/2, 0, 0);
tailPath = tailPath.transform(tailM4.storage);



虽然表面上看起来和顶点对齐了,但换个不水平的线就会看出端倪。我们需要 沿线的方向 进行平移,也就是说,要保证该直线过矩形区域圆心:



如下所示,我们在对断点进行平移时,需要根据线的角度来计算偏移量:



 Path headPath = head.formPath();
double fixDx = head.size.width/2*cos(line.direction);
double fixDy = head.size.height/2*sin(line.direction);

Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
headPath = headPath.transform(headM4.storage);
Path tailPath = tail.formPath();
Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
tailPath = tailPath.transform(tailM4.storage);



4.箭头的绘制

每个 PortPath 都有一个矩形区域,接下来只要专注于在该区域内绘制箭头即可。比如下面的 p0p1p2 可以形成一个三角形:



对应代码如下:


class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}

由于在 PortPath 中无法感知到子级是头还是尾,所以下面可以看出两个箭头都是向左的。处理方式也很简单,只要转转 180° 就行了。





另外,这样虽然看起来挺好,但也有和上面类似的问题,当改变坐标时,就会出现不和谐的情景。解决方案和前面一样,为断点的箭头根据线的倾角添加旋转变换即可。





如下进行旋转,即可得到期望的箭头,tag3 处可以顺便旋转 180° 把尾点调正。这样任意指定两点的坐标,就可以得到一个箭头。



Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
center = head.position;
headM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
headM4.multiply(Matrix4.rotationZ(line.direction));
headM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
headPath = headPath.transform(headM4.storage);

Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
center = tail.position;
tailM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
tailM4.multiply(Matrix4.rotationZ(line.direction-pi)); // tag3
tailM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
tailPath = tailPath.transform(tailM4.storage);



5.箭头的拓展

从上面可以看出,这个箭头断点的拓展能力是很强的,只要在矩形区域内形成相应的路径即可。比如下面带两个尖角的箭头形式,路径生成代码如下:



class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
return pathBuilder(zone);
}

Path pathBuilder(Rect zone){
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
final double rate = 0.8;
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
Offset p3 = p0.translate(rate*zone.width, 0);
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)
..lineTo(p3.dx, p3.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}

这样如下所示,只要更改 pathBuilder 中的路径构建逻辑,就可以得到不同的箭头样式。而且你只需要在矩形区域创建正着的路径即可,箭头跟随直线的旋转已经被封装在了 ArrowPath 中。这就是 屏蔽细节 ,简化使用流程。不然创建路径时还有进行角度偏转计算,岂不麻烦死了。





到这里,多样式的箭头设置方案应该就呼之欲出了。就像是 Flutter 动画中的各种 Curve 一样,通过抽象进行衍生,实现不同类型的数值转变。这里我们也可以对路径构建的行为进行抽象,来衍生出各种路径类。这样的好处在于:在实现类中,可以定义额外的参数,对绘制的细节进行控制。

如下,抽象出 PortPathBuilder ,通过 fromPathByRect 方法,根据矩形区域生成路径。在 PortPath 中就可以依赖 抽象 来完成任务:


abstract class PortPathBuilder{
const PortPathBuilder();
Path fromPathByRect(Rect zone);
}

class PortPath extends AbstractPath {
final Offset position;
final Size size;
PortPathBuilder portPath;

PortPath(
this.position,
this.size, {
this.portPath = const CustomPortPath(),
});

@override
Path formPath() {
Rect zone = Rect.fromCenter(
center: position, width: size.width, height: size.height);
return portPath.fromPathByRect(zone);
}
}



在使用时,可以通过指定 PortPathBuilder 的实现类,来配置不同的端点样式,比如实现一开始那个常规的 CustomPortPath :


class CustomPortPath extends PortPathBuilder{
const CustomPortPath();

@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}



以及三个箭头的 ThreeAnglePortPath ,我们可以将 rate 提取出来,作为构造入参,这样就可以让箭头拥有更多的特性,比如下面是 0.50.8 的对比:



class ThreeAnglePortPath extends PortPathBuilder{
final double rate;

ThreeAnglePortPath({this.rate = 0.8});

@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
Offset p3 = p0.translate(rate * zone.width, 0);
path
..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p3.dx, p3.dy)
..lineTo(p2.dx, p2.dy)
..close();
return path;
}
}



想要实现箭头不同的端点类型,只有在构造 PortPath 时,指定对应的 portPath 即可。如下红色箭头的两端分别使用 ThreeAnglePortPathCirclePortPath



ArrowPath arrow = ArrowPath(
head: PortPath(
p0.translate(40, 0),
const Size(10, 10),
portPath: const ThreeAnglePortPath(rate: 0.8),
),
tail: PortPath(
p1.translate(40, 0),
const Size(8, 8),
portPath: const CirclePortPath(),
),
);

这样一个使用者可以自由拓展的箭头绘制小体系就已经能够完美运转了。大家可以基于此体会一下其中 抽象 的意义,以及 多态 的体现。本篇中有很多旋转变换的绘制小技巧,下一篇,我们来一起绘制各种各样的 PortPathBuilder 实现类,以此丰富箭头绘制,打造一个小巧但强大的箭头绘制库。


作者:张风捷特烈
链接:https://juejin.cn/post/7120010916602576926
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

谈一谈凑单页的那些优雅设计(上)

本文将详细介绍作者如何在业务增长的情况下重构与优化系统设计。写在前面凑单页存在的历史也算是比较悠久了,我从去年接手到现在也经历不少的版本变更,最开始只是简单feeds流,为提升用户体验,更好的帮助用户去凑到满意的商品,我们在重构整个凑单页的同时,还新增了榜单、...
继续阅读 »

本文将详细介绍作者如何在业务增长的情况下重构与优化系统设计。

写在前面

凑单页存在的历史也算是比较悠久了,我从去年接手到现在也经历不少的版本变更,最开始只是简单feeds流,为提升用户体验,更好的帮助用户去凑到满意的商品,我们在重构整个凑单页的同时,还新增了榜单、限时秒杀模块,在双十一期间,加购率和转化率得到明显提升。今年618还新增了凑单进度购物栏模块,支持了实时凑单进度展示以及结算下单的能力,提升用户凑单体验。并且在凑单页完成业务迭代的同时,也一路沉淀了些通用的能力支撑其他业务快速迭代,本文我将详细介绍我是如何在业务增长的情况下重构与优化系统设计的。


针对一些段时间内不会变化的,数量比较有限的数据,为了减少下游的压力,并提高自身系统的性能,我们常常会使用多级缓存来达到该目的。最常见的就是本地缓存 + redis缓存来承接,如果本地缓存不存在,则取redis缓存的数据,并本地缓存起来,如果redis也不存在,则再从数据源获取,基本代码(获取榜单数据)如下:

return LOCAL_CACHE.get(key, () -> {
  String cache = rdbCommonTairCluster.get(key);
  if (StringUtils.isNotBlank(cache)) {
      return JSON.parseObject(cache, new TypeReference<List<ItemShow>>(){});
  }
  List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
  rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
  return itemShows;
});

逐渐的就出现了问题,线上偶现某些用户一段时间看不到榜单模块。榜单模块示意图如下:


这种问题排查起来最是棘手,需要一定的项目经验,我第一次遇到这类问题也是费了老大劲。总结一下,如果某次缓存过期,下游服务刚好返回了空结果,就会导致本次请求被缓存了空结果。那该缓存的生命周期内,榜单模块都会消失,但由于某些机器本地缓存还有旧数据,就会导致部分用户能看到,部分用户看不到的场景。

下面来看看我是如何优化的。核心主要关注:区分下游返回的结果是真的空还是假的空,本身就为空的情况下,就该缓存空集合(非大促期间或者某些榜没有数据,数据本身就为空)


在redis中拉长value缓存的时间,同时新增一个可更新时间的缓存(比如60s过期),当判断更新时间缓存过期了,就重新读取数据源,将value值重新赋值,这里需要注意,我会对比新老数据,如果新数据为空,老数据不为空,则只是更新时间,不置换value。value随着自己的过期时间结束,改造后的代码如下:

return LOCAL_CACHE.get(key, () -> {
  String updateKey = getUpdateKey(key);
  String value = rdbCommonTairCluster.get(key);
  List<ItemShow> cache = StringUtils.isBlank(cache) ? Collections.emptyList()
      : JSON.parseObject(value, new TypeReference<List<ItemShow>>(){});
  if (rdbCommonTairCluster.exists(updateKey)) {
      return cache;
  }
  rdbCommonTairCluster.set(updateKey, currentTime, cacheUpdateSecond);
  List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
  if (CollectionUtils.isNotEmpty(itemShows)) {
      rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
  }
  return itemShows;
});

为了使这段代码能够复用,我将该多级缓存抽象出来一个独立对象,代码如下:

public class GatherCache<V> {
  @Setter
  private Cache<String, List<V>> localCache;
  @Setter
  private CenterCache centerCache;

  public List<V> get(boolean needCache, String key, @NonNull Callable<List<V>> loader, Function<String, List<V>> parse) {
      try {
          // 是否需要是否缓存
          return needCache ? localCache.get(key, () -> getCenter(key, loader, parse)) : loader.call();
      } catch (Throwable e) {
          GatherContext.error(this.getClass().getSimpleName() + " get catch exception", e);
      }
      return Collections.emptyList();
  }

  private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {
      String updateKey = getUpdateKey(key);
      String value = centerCache.get(key);
      boolean blankValue = StringUtils.isBlank(value);
      List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
      if (centerCache.exists(updateKey)) {
          return cache;
      }
      centerCache.set(updateKey, currentTime, cacheUpdateSecond);
      List<V> newCache = loader.call();
      if (CollectionUtils.isNotEmpty(newCache)) {
          centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
      }
      return newCache;
  }
}

将从数据源获取数据的代码交与外部实现,使用Callable的形式,同时通过泛型约束数据源类型,这里还有一点瑕疵还没得到解决,就是通过fastJson转换String到对象时,没法使用泛型直接转,我这里就采用了外部化的处理,就是跟获取数据源方式一样,由外部来决定如何解析从redis中获取到的字符串value。调用方式如下:

List<ItemShow> itemShowList = gatherCache.get(true, rankingRequest.getKey(),
  () -> getRankingItemOriginal(rankingRequest, context.getRequestContext()),
  v -> JSON.parseObject(v, new TypeReference<List<ItemShow>>() {}));

同时我还采用的建造者模式,方便gatherCache类快速生成,代码如下:

@PostConstruct
public void init() {
  this.gatherCache = GatherCacheBuilder.newBuilder()
      .localMaximumSize(500)
      .localExpireAfterWriteSeconds(30)
      .build(rdbCenterCache);
}

以上的代码相对比较完美了,却忽略了一个细节点,如果多台机器的本地缓存同时失效,恰好redis的可更新时间失效了,这时就会有多个请求并发打到下游(由于凑单有本地缓存兜底,并发打到下游的个数非常有限,基本可以忽略)。但遇到问题就需要去解决,追求完美代码。我做了如下的改造:

private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {
  String updateKey = getUpdateKey(key);
  String value = centerCache.get(key);
  boolean blankValue = StringUtils.isBlank(value);
  List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
  // 如果抢不到锁,并且value没有过期
  if (!centerCache.setNx(updateKey, currentTime) && !blankValue) {
      return cache;
  }
  centerCache.set(updateKey, currentTime, cacheUpdateSecond);
  // 使用异步线程去更新value
  CompletableFuture.runAsync(() -> updateCache(key, loader));
  return cache;
}

private void updateCache(String key, Callable<List<V>> loader) {
  List<V> newCache = loader.call();
  if (CollectionUtils.isNotEmpty(newCache)) {
    centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
  }
}

本方案使用分布式锁 + 异步线程的方式来处理更新。只会有一个请求抢到更新锁,并发情况下,其他请求在可更新时间段内还是返回老数据。由于redis封装的方法中并没有抢锁后同时设置过期时间的原子性操作,我这里用了先抢锁,再赋值过期时间的方式,在极端场景下可能会出现死锁的情况,就是刚好抢到了锁,然后机器出现异常宕机,导致过期时间没有赋值上去,就会出现永远无法更新的情况。这种情况虽然极端,但还是要解,以下是我能想到的两个方案,我选择了第二种方式:

  1. 通过使用lua脚本将两步操作合成一个原子性操作

  2. 利用value的过期时间来解该死锁问题


P.S. 一些从ThreadLocal中拿的通用信息,在使用异步线程处理的时候是拿不到的,得重新赋值

凑单核心处理流程设计

凑单本身是没有自己的数据源的,都是从其他服务读取,做各种加工后展示。这样的代码是最好写的,也是最难写的。就好比最简单的组装商品信息,一般的代码都会这么写:

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  itemShow.setItemId(NumberUtils.createLong(v.get("itemId")));
  itemShow.setItemImg(v.get("pic"));
  // 获取利益点
  GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
  AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
      .extract(guideInfoDTO, "gather", "item");
  List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
  itemShow.setItemBenefits(iconTexts);
  // 预售处理
  String preSalePrice = getPreSale(v);
  if (Objects.nonNull(preSalePrice)) {
      itemShow.setItemPrice(preSalePrice);
  }
  // ......
  return itemShow;
}).collect(Collectors.toList());

能快速写好代码并投入使用,但代码有点杂乱无章,对代码要求比较高的开发者可能会做如下的改进

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  buildCommon(itemShow, v);
  // 获取利益点
  buildAtmosphere(itemShow, v);
  // 预售处理
  buildPreSale(itemShow, v);
  // ......
  return itemShow;
}).collect(Collectors.toList());

一般这样的代码算是比较优质的处理了,但这仅仅是针对单个业务,如果遇到多个业务需要使用该组装后,最简单但就是需要判断是来自feeds流模块的请求商品组装不需要利益点,来自前N秒杀模块的不需要处理预售价格。

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  buildCommon(itemShow, v);
  // 获取利益点
  if (!Objects.equals(soluction, FiltrateFeedsSolution.class)) {
      buildAtmosphere(itemShow, v);
  }
  // 预售处理
  if (!Objects.equals(source, "seckill")) {
      buildPreSale(itemShow, v);
  }
  // ......
  return itemShow;
}).collect(Collectors.toList());

该方案可以清晰看到整个主流程的分流结构,但会使得主流程不够整洁,降低可读性,很多人都习惯把该判断写到各自的方法里如下。(当然也有人每个模块都单独写一个主流程,以上只是为了文章易懂简化了代码,实际主流程较长,并且大部分都是需要处理的,如果每个模块都单独自己创建主流程,会带来很多重复代码,不推荐)

private void buildAtmosphere(ItemShow itemShow, Map<String, String> map) {
  if (Objects.equals(soluction, FiltrateFeedsSolution.class)) {
      return;
  }
  GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
  AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
      .extract(guideInfoDTO, "gather", "item");
  List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
  itemShow.setItemBenefits(iconTexts);
}

纵观整个凑单的业务逻辑,不管是参数组装,商品组装,购物车组装,榜单组装,都需要信息组装的能力,并且他们都有如下的特性:

  1. 每个或每几个字段的组装都不影响其他字段,就算出现异常也不应该影响其他字段的拼装

  2. 在消费者链路下,性能的要求会比较高,能不用访问的组装逻辑就不去访问,能不调用下游,就不去调用下游

  3. 如果在组装的过程中发现有写字段是必须要的,但没有补全,则提前终止流程

  4. 每个方法的处理需要记录耗时,开发能清楚的知道耗时在哪些地方,方便找到需要优化的代码

以上的点都很小,不做或者单独做都不影响整体,凑单页含有这么多组装逻辑的情况下,如果以上逻辑全部都写一遍,将产生大量的冗余代码。但对自己代码要求比较高的人来说,这些点不加上去,心里总感觉有根刺在。慢慢的就会因为自己之前设计考虑的不全,打各种补丁,就好比想知道某个方法的耗时,就会写如下代码:

long startTime = System.currentTimeMillis();
// 主要处理
buildAtmosphere(itemShow, summaryMap);
long endTime = System.currentTimeMillis();
return endTime - startTime;

凑单各域都是做此类型的组装,有商品组装,参数组装,榜单组装,购物车组装。针对凑单业务的特性,寻遍各类设计模式,最终选择了责任链 + 命令模式。

在 GoF 的《设计模式》中,责任链模式是这么定义的:

将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,

直到链上的某个接收对象能够处理它为止。

*首先,我们来看,职责链模式如何应对代码的复杂性。*

将大块代码逻辑拆分成函数,将大类拆分成小类,是应对代码复杂性的常用方法。应用职责链模式,我们把各个商品组装继续拆分出来,设计成独立的类,进一步简化了商品组装类,让类的代码不会过多,过复杂。

*其次,我们再来看,职责链模式如何让代码满足开闭原则,提高代码的扩展性。*

当我们要扩展新的组装逻辑的时候,比如,我们还需要增加价格隐藏过滤,按照非职责链模式的代码实现方式,我们需要修改主类的代码,违反开闭原则。不过,这样的修改还算比较集中,也是可以接受的。而职责链模式的实现方式更加优雅,只需要新添加一个Command 类(实际处理类采用了命令模式做一些业务定制的扩展),并且通过 addCommand() 函数将它添加到 Chain 中即可,其他代码完全不需要修改。

接下来就是使用该模式,对凑单全域进行改造升级,核心架构图如下


各个域需要满足如下条件:

  1. 支持单个处理和批量处理

  2. 支持提前阻断

  3. 支持前置判断是否需要处理

处理类类图如下


【ChainBaseHandler】:核心处理类

【CartHandler】:加购域处理类

【ItemSupplementHandler】:商品域处理类

【RankingHandler】:榜单域处理类

【RequestHanlder】:参数域处理类

我们首先来看核心处理层:

public class ChainBaseHandler<T extends Context> {
  /**
    * 任务执行
    * @param context
    */
  public void execute(T context) {
      List<String> executeCommands = Lists.newArrayList();
      for (Command<T> c : commands) {
          try {
              // 前置校验
              if (!c.check(context)) {
                  continue;
              }
              // 执行
              boolean isContinue = timeConsuming(() -> execute(context, c), c, executeCommands);
              if (!isContinue) {
                  break;
              }
          } catch (Throwable e) {
              // 打印异常信息
              GatherContext.debug("exception", c.getClass().getSimpleName());
              GatherContext.error(c.getClass().getSimpleName() + " catch exception", e);
          }
      }
      // 打印个命令任务耗时
      GatherContext.debug(this.getClass().getSimpleName() + "-execute", executeCommands);
  }
}

中间的timeConsuming方法用来计算耗时,耗时需要前后包裹执行方法

private boolean timeConsuming(Supplier<Boolean> supplier, Command<T> c, List<String> executeCommands) {
  long startTime = System.currentTimeMillis();
  boolean isContinue = supplier.get();
  long endTime = System.currentTimeMillis();
  long timeConsuming = endTime - startTime;
  executeCommands.add(c.getClass().getSimpleName() + ":" + timeConsuming);
  return isContinue;
}

具体执行如下:

/**
* 执行每个命令
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(Context context, Command<T> c) {
  if (context instanceof MuchContext) {
      return execute((MuchContext<D>) context, c);
  }
  if (context instanceof OneContext) {
      return execute((OneContext<D>) context, c);
  }
  return true;
}

/**
* 单数据执行
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(OneContext<D> oneContext, Command<T> c) {
  if (Objects.isNull(oneContext.getData())) {
      return false;
  }
  if (c instanceof CommonCommand) {
      return ((CommonCommand<OneContext<D>>) c).execute(oneContext);
  }
  return true;
}

/**
* 批量数据执行
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(MuchContext<D> muchContext, Command<T> c) {
  if (CollectionUtils.isEmpty(muchContext.getData())) {
      return false;
  }
  if (c instanceof SingleCommand) {
      muchContext.getData().forEach(data -> ((SingleCommand<MuchContext<D>, D>) c).execute(data, muchContext));
      return true;
  }
  if (c instanceof CommonCommand) {
      return ((CommonCommand<MuchContext<D>>) c).execute(muchContext);
  }
  return true;

入参都是统一的context,其中的data为需要拼装的数据。类图如下


MuchContext(多值的数据拼装上下文),data是个集合

public class MuchContext<D extends ContextData> implements Context {

  protected List<D> data;

  public void addData(D d) {
      if (CollectionUtils.isEmpty(this.data)) {
          this.data = Lists.newArrayList();
      }
      this.data.add(d);
  }

  public List<D> getData() {
      if (Objects.isNull(this.data)) {
          this.data = Lists.newArrayList();
      }
      return this.data;
  }
}

OneContext(单值的数据拼装上下文),data是个对象

public class OneContext <D extends ContextData> implements Context {
  protected D data;
}

各域可根据自己需要实现,各个实现的context也使用了领域模型的思想,将对入参的一些操作封装在此,简化各个命令处理器的获取成本。举个例子,比如入参是一系列操作集合 List<HandleItem> handle。但实际使用是需要区分各个操作,那我们就需要在context中做好初始化,方便获取:

private void buildHandle() {
  // 勾选操作集合
  this.checkedHandleMap = Maps.newHashMap();
  // 去勾选操作集合
  this.nonCheckedHandleMap = Maps.newHashMap();
  // 修改操作集合
  this.modifyHandleMap = Maps.newHashMap();
  Optional.ofNullable(requestContext.getExtParam())
      .map(CartExtParam::getHandle)
      .ifPresent(o -> o.forEach(v -> {
          if (Objects.equals(v.getType(), CartHandleType.checked)) {
              checkedHandleMap.put(v.getCartId(), v);
          }
          if (Objects.equals(v.getType(), CartHandleType.nonChecked)) {
              nonCheckedHandleMap.put(v.getCartId(), v);
          }
          if (Objects.equals(v.getType(), CartHandleType.modify)) {
              modifyHandleMap.put(v.getCartId(), v);
          }
      }));
}

下面来看各个命令处理器,类图如下:


命令处理器主要分为SingleCommand和CommonCommand,CommonCommand为普通类型,即将data交与各个命令自行处理,而SingleCommand则是针对批量处理的情况下,将data集合提前拆好。两个核心区别就在于一个在框架层执行data的循环,一个是在各个命令层处理循环。主要作用在于:

  1. SingleCommand减少重复循环代码

  2. CommonCommand针对下游需要批量处理的可提高性能

续  谈一谈凑单页的那些优雅设计(下)

作者:鸣翰(郑健) 大淘宝技术 

收起阅读 »