wenmo8 发布的文章

前言

在鸿蒙OS的广阔天地中,开发者们有机会创造出令人惊叹的用户体验。最近,我着手设计一款具有独特滑动效果的Swiper组件,它在滑动时能够迅速进入视野,同时巧妙地将旧的cell隐藏到视线之外。本文将分享如何利用鸿蒙的Swiper组件,实现这一引人入胜的动态效果。

一、设计与构思

Swiper的设计理念是简洁而富有动感。每个cell在滑动时不仅会逐渐缩小至原始大小的70%,还会被前一个cell覆盖,创造出一种流畅且连续的视觉效果。这种效果的实现,依赖于精确的动画控制和布局调整。

二、代码设计与实现思路

实现这一效果,我们需要对Swiper组件进行深度定制。这包括对cell的尺寸、位置和层级进行动态调整,以及利用贝塞尔曲线来实现平滑的动画效果。

三、控件采用与代码说明

3.1 Swiper组件定制

Swiper组件提供了丰富的API,允许我们对其行为进行精细控制。以下是一些关键的配置项和它们的作用:

  • itemSpace
    : 控制cell之间的间距。
  • indicator
    : 是否显示指示器。
  • displayCount
    : 设置同时展示的cell数量。
  • onAreaChange
    : 当Swiper区域大小变化时的回调。
  • customContentTransition
    : 自定义内容转换动画。

Swiper组件基础配置代码:

Swiper()
  .itemSpace(12)
  .indicator(false)
  .displayCount(this.DISPLAY_COUNT)
  .padding({left:10, right:10})
  .onAreaChange((oldValue, newValue) => {
    // 处理区域变化逻辑
  })
  .customContentTransition({
    transition: (proxy) => {
      // 自定义转换逻辑
    }
  });
3.2 Item组件设置

每个Item需要根据其在Swiper中的位置进行尺寸、位置和层级的调整。这涉及到初始化相关变量,并在
aboutToAppear
生命周期方法中进行设置。

初始化宽高,初始化组件数据:

  @State cw: number = 0;
  @State ch: number = 0;
  
  aboutToAppear(): void {
    initSwipe(...)
  }

  initSwipe(num:number){
    this.translateList = []
    for (let i = 0; i < num; i++) {
      this.scaleList.push(0.8)
      this.translateList.push(0.0)
      this.zIndexList.push(0)
    }
  }
  private MIN_SCALE: number = 0.70
  private DISPLAY_COUNT: number = 4
  private DISPLAY_WIDTH: number = 200
  @State scaleList: number[] = []
  @State translateList: number[] = []
  @State zIndexList: number[] = []

Item尺寸和位置设置代码:

LifeStyleItem({lifeStyleResponse: item})
  .scale({ x: this.scaleList[index], y: this.scaleList[index] })
  .translate({ x: this.translateList[index] })
  .zIndex(this.zIndexList[index]);

在 customContentTransition的transition 属性中设置属性:


//scaleList 需要进行线性变化
//translateList 位移需要进行 数据偏移处理和贝塞尔曲线处理
//zIndexList 需要进行位置层级设置

this.scaleList[proxy.index] = 线性函数
this.translateList[proxy.index] = - proxy.position * proxy.mainAxisLength + 贝塞尔曲线函数
this.zIndexList[proxy.index] = proxy.position
3.3 自定义动画效果

为了实现平滑的动画效果,我们定义了三次贝塞尔曲线函数和线性函数。这些函数将用于计算cell在滑动过程中的尺寸、位置和层级变化。

三次贝塞尔曲线函数:

function cubicBezier8(t, a1, b1, a2, b2) {
  // 计算三次贝塞尔曲线的值
  
const k1 = 3 * a1;
const k2 = 3 * (a2 - b1) - k1;
const k3 = 1 - k1 - k2;
return k3 * Math.pow(t, 3) + k2 * Math.pow(t, 2) + k1 * t;

}

线性函数:

function chazhi(startPosition, endPosition, startValue, endValue, position) {
  // 计算线性插值的结果
 

const range = endPosition - startPosition;
const positionDifference = position - startPosition;
const fraction = positionDifference / range;

const valueRange = endValue - startValue;
const result = startValue + (valueRange * fraction);

return result;

}
3.4 计算函数实现

我们编写了计算函数来确定cell在Swiper中的最终表现。这包括根据位置计算尺寸、位置和层级。

计算尺寸和位置的函数:

function calculateValue(width: number, position: number): number {
  const minValue = 0;

  const normalizedPosition = position / 4;

  // 计算贝塞尔曲线的缓动值
  const easedPosition = cubicBezier(normalizedPosition, 0.3, 0.1, 1,  0.05);

  // 根据缓动值计算最终的变化值
  const value = minValue + (width - minValue) * easedPosition;
  return value;
}

function calculateValueScale(position) {

if (position >= 2.5) {
  // 当position大于2时,值固定为0.8
  return 0.8;
} else if (position < 2.5) {
  const startPosition = 2.5;
  const endPosition = -1;
  // 定义返回值的起始值和结束值
  const startValue = 0.8;
  const endValue = 0.7;
  return chazhi(startPosition,endPosition,startValue,endValue,position)
}

return 0.7;

}

四、全部代码整合

将上述所有代码片段整合到一个组件中,确保Swiper和每个Item都能够根据用户的滑动操作动态调整。
代码如下:


function calculateValue(width: number, position: number): number {
  const minValue = 0;
  const normalizedPosition = position / 4;
  const easedPosition = cubicBezier8(normalizedPosition, 0.3, 0.1, 1,  0.05);
  const value = minValue + (width - minValue) * easedPosition;
  return value;
}

function cubicBezier(t: number, a1: number, b1: number, a2: number, b2: number): number {
  const k1 = 3 * a1;
  const k2 = 3 * (a2 - b1) - k1;
  const k3 = 1 - k1 - k2;
  return k3 * Math.pow(t, 3) + k2 * Math.pow(t, 2) + k1 * t;
}

function calculateValueScale(position: number): number {
  if (position >= 2.5) {
    return 0.8;
  } else if (position < 2.5) {
    const startPosition = 2.5;
    const endPosition = -1;
    const startValue = 0.8;
    const endValue = 0.7;
    return chazhi(startPosition,endPosition,startValue,endValue,position)
  }
  return 0.7;
}

function chazhi(startPosition:number,endPosition:number,startValue:number,endValue:number,position:number):number{

  const range = endPosition - startPosition;
  const positionDifference = position - startPosition;
  const fraction = positionDifference / range;

  const valueRange = endValue - startValue;
  const result = startValue + (valueRange * fraction);

  return result;
}

@Component
struct Banner {
  @State cw: number = 0;
  @State ch: number = 0;
  aboutToAppear(): void {
  initSwipe()
  }

  initSwipe(num:number){
    this.translateList = []
    for (let i = 0; i < num; i++) {
      this.scaleList.push(0.8)
      this.translateList.push(0.0)
      this.zIndexList.push(0)
    }
  }
  private MIN_SCALE: number = 0.70
  private DISPLAY_COUNT: number = 4
  private DISPLAY_WIDTH: number = 200
  @State scaleList: number[] = []
  @State translateList: number[] = []
  @State zIndexList: number[] = []
  
  
  build(){
  Swiper() {
          ForEach(this.lifeStyleList, (item: LifeStyleResponse|null,index) => {
            LifeStyleItem({lifeStyleResponse:item})
              .scale({ x: this.scaleList[index], y: this.scaleList[index] })
              .translate({ x: this.translateList[index] })
              .zIndex(this.zIndexList[index])
          }
          )
        }
        .itemSpace(12)
        .indicator(false)
        .displayCount(this.DISPLAY_COUNT)
        .padding({left:10,right:10})
        .onAreaChange((oldValue,newValue)=>{
          this.cw = new Number(newValue.width).valueOf()
          this.ch = new Number(newValue.height).valueOf()
        })
        .customContentTransition({
          transition :(proxy: SwiperContentTransitionProxy)=>{
            this.scaleList[proxy.index] = calculateValueScale(proxy.position)
            this.translateList[proxy.index] = - proxy.position * proxy.mainAxisLength + calculateValue8(this.cw,proxy.position)
            this.zIndexList[proxy.index] = proxy.position
          }
        })
  }

五、总结

通过本文的深入解析,我们不仅实现了一个具有个性化动态效果的Swiper组件,还学习了如何利用鸿蒙OS的强大API来定制动画和布局。希望这篇文章能够激发更多开发者的创造力,共同探索鸿蒙OS的无限可能。

作者:来自 vivo 互联网大前端团队-  Wei Xing

在研发小型项目时,传统的 Vue、React 显得太“笨重”。本文主要针对开发小型项目的场景,谈谈 Vite+Svelte 是如何让项目变得“小巧灵动”,并横向对比 Svelte 和 Vue 的性能表现,对二者的加载流程做详细分析。

一、背景

为了统一技术标准、提升协作效率,通常在前端团队内部只会保留一套通用的研发框架。尤其是在团队初创时期,团队成员会考虑易用性、社区活跃程度、学习成本等因素,选择一个合适的研发框架并一直推行和使用下去。

国内的前端团队比较青睐 Vue和 React,我们团队内部的主要研发框架也是 Vue,包括组件库、工具库、脚手架等等,都是围绕 Vue 展开来做研发。

坚持使用一个技术栈虽然让团队协作变得高效,也不用重复“造轮子”,同时提升了人员“流通性”,有它不可忽略的优势。但没有任何一款框架是“银弹”,例如 Vue,它的通用性很好,但在某些特殊场景下,我们会有更好的选择。

例如,当我们在开发一些小型项目时,会发现至少有两个明显的问题:

  • 框架太“重”了
    :通常一个小型项目只由少数几个简单页面构成,如果使用 Vue 或者 React 这些框架来研发的话,有点“大材小用”了。构建的产物中包含了不少框架运行时代码(虚拟 DOM、响应式、状态管理等),这些代码对于小型项目而言是冗余的,它们影响了包体大小,进而影响页面的启动速度和执行性能。

  • 打包太慢了
    :以 Vue CLI 为例,它的底层基于 Webpack,虽然 Webpack 具备更强大的功能和灵活性,但相比于 Vite、Esbuild 这些以速度为标杆的构建工具来说,它的速度确实慢了一些,影响了研发效率。

面对这两个问题,我们似乎有更好的技术方案可选:
使用更轻量的 Vite + Svelte
。本文就是针对开发小型项目的场景,谈谈 Vite+Svelte 是如何让项目变得“
小巧灵动
”。

注意
:本篇所有针对 Svelte 的性能观点,都是基于小型项目这个前提下提出。事实上,随着项目规模的增长, Svelte 的性能、包体大小优势会逐渐减小,甚至不如 Vue 或 React,
详情可参考尤雨溪尤大本人针对 Svelte 和 Vue3的包体大小问题的分析

理论上在普通 CSR 项目中,组件数量超过19个时, Svelte 就失去了它的包体大小优势。

二、Vite 和 Svelte 简介

先了解下 Vite 和 Svelte。

2.1 Vite

Vite 是尤雨溪尤大写的一款高效的前端构建工具,相比于 Webpack,它最大的优势就是“

”。

在开发环境下,Vite 使用高性能的 Esbuild 来进行预构建,并利用现代浏览器对 ESM 的支持,直接将预构建好的ES模块丢给浏览器进行解析执行,无需在每次变更代码时都重新编译代码,具有更快的冷启动速度和热更新效率。

在生产环境下,Vite 基于 Rollup 进行打包,Rollup 同样支持 ESM 语法,并且具有更快速高效的 Treeshaking,一般情况下,Rollup 具有更小的包体大小和更快的构建速度。

参考 github 上的构建工具横向对比 benchmark

图片

(图片来源:https://github.com)

可以看到无论是冷启动、热更新还是生产环境打包,Vite 都是优于 Webpack 的。尤其是在开发过程中的热更新很快,大大优化了开发体验。

2.2 Svelte

Svelte 是由 Rollup 的作者 Rich Harris(前端轮子哥) 写的一款前端框架。在语法上,Svelte 和 Vue 类似。它和传统框架(如 Vue、React)的最大差异就在于:在构建阶段,Svelte 就将代码编译为“纯粹”的 JavaScript 代码,几乎没有运行时。

这意味着在小型项目中,它打出来的包更小,在运行时,它也不需要复杂的状态管理和虚拟 DOM,在性能上的表现也更好。

在前端大佬 Jacek Schae 的前端框架横向测评中(测评简述:使用各个前端框架来编写同一个标准 App - RealWorld,并横向对比它们的表现),可以看到,Svelte 无论是在首屏渲染速度、包体大小还是代码行数上都展现了较大的优势,全部跻身前三。

首屏渲染速度

图片

(图片来源:https://medium.com)

包体大小
:

图片

(图片来源:https://medium.com)

代码行数
:

图片

(图片来源:https://medium.com)

三、搭建 Vite+Svelte 项目

在基本了解了 Vite 和 Svelte 之后,来看看如何着手去搭建一个 Vite+Svelte 的项目。

3.1 全局安装 Vite

通过 npm 全局安装 Vite:

npm install vite -g

3.2 创建 Svelte 项目

Vite 原生支持直接通过脚手架创建 Svelte 项目,执行以下命令:

npm create vite@latest

命令行中会出现引导,按照提示输入项目的名称并直接选择 Svelte 框架即可。

✔ Project name: vite-svelte-demo

? Select a framework: › - Use arrow-keys. Return to submit.
    Vanilla
    Vue
    React
    Preact
    Lit
❯   Svelte
    Solid
    Qwik
    Others

? Select a variant: › - Use arrow-keys. Return to submit.
    TypeScript
❯   JavaScript
    SvelteKit ↗

3.3 运行项目

通过以下命令运行项目:

cd vite-svelte-demo
npm install
npm run dev

图片

这样一来,整个 Vite + Svelte 的项目结构就搭建好了,开箱即用。整个项目的目录结构如下:

|-node_modules        -- 项目依赖
|-public              -- 公共文件
  |--vite.svg        
|-src                 -- 源文件
  |-assets           
  |-lib               
  |--App.svelte       -- 项目根组件
  |--app.css          -- 项目根样式
  |--main.js          -- 项目入口文件
|--.gitignore         
|-- index.html        -- 首页index.html
|-- package-lock.json 
|-- package.json      
|-- tsconfig.json 
|-- svelte.config.js  -- svelte配置文件
|-- vite.config.js    -- vite配置文件

可以看到,整个项目结构基本和 Vue 类似,只是组件的后缀名改为了.svelte,新增了 vite.config.js 和  svelte.config.js 两个配置文件。

3.4 配置

Vite+Svelte 支持开箱即用,项目的初始配置也很简单,没有任何额外配置。

vite.config.js
:用于配置 Vite 构建工具的行为,例如入口文件、输出目录、插件、devServer 等。

// vite.config.js
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'

export default defineConfig({
  plugins: [svelte()],
})

svelte.config.js
:用于配置 Svelte 项目的各种选项,例如别名、预处理器、插件等。

// svelte.config.js
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

export default {
  preprocess: vitePreprocess(),
}

在工程配置这里,唯一需要注意的是,Vite 默认情况打出的包体仅支持现代浏览器(支持 ESM ),如果要兼容低版本浏览器,可以使用官方提供的 @vitejs/plugin-legacy插件:

// vite.config.js
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import legacy from "@vitejs/plugin-legacy";

export default defineConfig({
  plugins: [
    svelte(),
    legacy({
      // 设置需要兼容的目标浏览器版本
      targets: [
        "Android >= 4.4",
        "iOS >= 9",
        "ie >= 11",
        "not Android < 4.4",
        "not iOS < 9",
        "not ie < 11",
      ],
      additionalLegacyPolyfills: ["regenerator-runtime/runtime"],
      renderLegacyChunks: true,
    }),
});

更多配置项可参考官网的 Vite 配置文档和 Svelte 配置文档。

四、开发体验优化

项目搭建完成后,后续就是根据业务需求来开发 Svelte 组件和完善业务逻辑。整个开发过程中,体验感还是不错的。

详细的开发流程不再赘述,想要了解更多关于 Vite 和 Svelte 内容,可以参考 Vite 官方文档和 Svelte 官方文档。这里我们主要来看看 Vite 和 Svelte 分别在开发体验上带来的一些优化。

4.1 Vite 的开发体验太棒了

首先,Vite 的开发体验太棒了。它的构建速度极快,对开发效率有很明显的提升。

在开发环境下,Webpack 每次都需要对改动的部分进行重新编译打包,耗时几秒钟,而 Vite 则不需要重新打包,只需要把更新后的 ESM 代码交付给浏览器就ok,几乎是即时更新。

同时在构建生产环境包时,也明显比原先的 Vue 项目要快了近50%。别小看这一点速度的提升,让整个开发体验好了不少。

4.2 Svelte 的语法更优雅?

如果你跟随上面步骤搭建了项目,那你可以打开项目中的 Counter.svelte 文件看看,会发现 Svelte 组件的基本结构和 Vue 几乎一致,也是由 script、template 和 style 三部分组成,唯一的差异在于 Svelte 中不需要像 Vue 一样额外使用来作为 DOM 结构的根标签。

<script>
  let count = 0
  const increment = () => {
    count += 1
  }
</script>

<button on:click={increment}>
  count is {count}
</button>

<style>
  ...
</style>

在语法上,Svelte 和 Vue 也非常相似,但个人更喜欢 Svelte 的一些简洁的语法设计,举一些例子:

插值表达式
:Svelte 的插值表达式只需要单个{},而 Vue 则需要两层{{}},并且 Svelte 还有一些简写语法,例如 src={src} 可以简写为 {src}。

// Svelte的插值表达式
<div {src}>
  count is {count}
</<div>

// Vue的插值表达式
<template>
  <div :src="src">
    count is {{ count }}
  </div>
</template>

响应式
:Svelte 声明的变量直接支持响应式,而在 Vue 中则需要使用 ref 来声明。

// Svelte
let message = 'Svelte';

// Vue
import { ref } from 'vue';
const message = ref('Vue');

computed 计算属性 / watch 监听
:Svelte 的$: 代码块类似于 Vue 的 computed 计算属性和 watch 状态监听的结合体。

// Svelte的计算属性
$: info = name + age

// Svelte的监听
let val
$: {
  console.log(`你输入的内容是: ${val}`);
}

// Vue的计算属性
import { computed } from 'vue';
const info = computed(() => {
  return name + age
});

// Vue的监听
import { watch } from 'vue';

let val = ref(''); 
watch(val, (newValue, oldValue) => { 
  console.log(`你输入的内容是: ${val}`);
});

虽然差异没有很大,但这些简洁的语法,至少让我个人感觉写项目时更流畅,体验感很不错。

这些就是 Vite 和 Svelte 带来的开发体验上的一些优化。

五、Svelte 和 Vue 性能对比

上面我们了解了如何搭建 Vite+Svelte 项目,并感受到 Vite 和 Svelte 带来的开发体验优化。那接下来看看这套方案在实际项目中的性能表现。

为了对比 Svelte 和 Vue 之间的性能差异,我特地找了一个小型项目进行改造,用 Svelte 对它重写,并通过 ABTest 进行线上的性能数据对比。

5.1 首先,来看打包之后的 bundle 大小对比

Vue 的包体

图片

Svelte 的包体

图片

可以看到 Svelte 相比于 Vue,在包体大小上确实优化了不少,基本在40%左右,效果很明显。

5.2 其次,看看本地的启动速度(FCP)对比

在本地进行10次平均性能测试,发现 Svelte 的 FCP 指标比 Vue 要快了约46%,说明 Svelte 的首屏渲染速度比 Vue 快了不少,提升很明显。

下面这个录屏是对比两个页面首次打开时的效果,左侧为 Svelte,右侧为 Vue。

图片

5.3 最后,看看线上的启动速度数据对比

我们通过 ABTest 进行线上的启动速度数据对比,在投放了1周后,数据结论如下:

图片

可以看到 Svelte 在线上的启动速度表现也比 Vue 要快一些,优化程度约为14.5%。

注意
:这里的平均启动速度指标是我们团队内部定义的页面启动速度指标,计算方式:业务启动速度 = 页面加载完成时间 - 页面入口点击时间。由于我们是在客户端 App 中进行测试,因此该时间包含了客户端 webview 的启动时间。所以实际上 Svelte 对前端部分的启动速度优化程度可能会更高,参考前面的 FCP 指标的本地测试结果。

总之,对于小型项目,Svelte 在包体大小和启动速度上还是有不少提升的,如果你的项目对启动速度有强要求,也可以尝试使用 Svelte 来开发或改造,应该会有不错的效果。

六、Svelte 比 Vue 快在哪?

通过上面的数据分析,虽然我们了解到 Svelte 在构建小型项目时确实有更快的页面启动速度,但具体快在哪,还需要进一步深入分析。

我们可以通过 Chrome 的 devtool 来观察 Svelte 和 Vue 两者打包后的页面在加载流程上的差异。

Vue 页面加载流程

图片

Svelte 页面加载流程

图片

对比二者的加载流程,可以看到耗时上有两个主要的差异点:

因此,在小型项目中,Svelte 相比于 Vue,有两个明显的优势:

  • 资源加载更快
    :由于包体较小,所以加载首屏 JS  Bundle 时网络耗时更短。

  • Html/JS解析执行更快
    :由于 Svelte 几乎无运行时,因此在执行相同业务逻辑时,解析执行耗时更短。并且在解析执行过程中,没有二次加载更多的首屏 JS、CSS 资源,因此整体的解析执行速度更快。

七、总结

总之,对于小型项目而言,Vite + Svelte 这对组合还是很不错的,既提升了开发效率,又优化了页面性能,更加“小巧灵动”。而且这两者的学习曲线都比较缓和,基本在2-3天内就可以完成学习加一个简单项目的改造。

一点题外话,很多时候大家会低估“技术尝新”的重要性,我们被困在团队圈定的“技术穹顶”中,永远用一个“套路”去实现所有业务,这会让我们感到疲乏、缺乏价值感和动力,甚至影响工作的投入程度,久而久之,整个团队也会在技术上变得迟滞。

所以在有机会时,要尽量合理地发掘和尝试新的技术方案,一方面是让自己保持技术活力,另一方面也是在加强团队的技术沉淀。

大家好,我是汤师爷~

概念模型设计是系统开发的关键步骤,它能帮助我们厘清业务逻辑并定义核心实体。我们将从订单履约的基本概念入手,深入探讨各实体间的关系。

履约域核心概念模型

如图展示了履约域的核心概念模型。

1、发货单

发货单是订单履约过程中的重要执行单据,它将订单转化为具体的发货任务。门店和仓储部门通过发货单进行拣货、打包并安排物流服务商发货。发货单确保货物从仓库到配送环节的流转过程有序、清晰、可追踪。

发货单的核心字段包含以下关键信息:

  • 发货单ID:系统生成的唯一标识,用于追踪和管理每笔发货记录
  • 所属订单ID:关联的主订单编号,确保发货单与订单的对应关系
  • 仓库/门店ID:负责发货的具体库存地点,可以是中央仓库或线下门店
  • 单据类型:标识发货单的业务类型,如销售发货、换货发货等
  • 发货方式:具体的配送方式选择,如快递、同城配送或自提等
  • 物流服务商:负责运输的快递公司或配送服务商信息
  • 实际发货时间:商品从仓库或门店实际出库的时间点
  • 单据状态:发货单在系统中的处理进度,如待发货、已发货、已签收等
  • 物流费用:该发货单产生的配送费用

2、发货单明细

发货单明细是发货单下所包含的具体商品项记录。每条发货单明细对应发货单中某个商品的实际发货信息,包括商品ID、SKU_ID、数量、重量等属性。

3、收货信息

记录收货人姓名、手机号码、联系电话和详细地址等。

4、交付信息

包括预计送货和送达时间、预约送达时间范围、预约自提时间范围、自提位置和配送方式等交付细节。

5、配送单

配送单是将已完成打包的包裹分配给物流服务商进行配送的业务单据。配送员通过配送单了解需配送的包裹信息,从而高效完成送货任务。

配送单包含以下关键字段:

  • 配送单ID:系统生成的唯一标识,用于追踪每笔配送记录
  • 所属发货单ID:关联的发货单编号,标识配送单与发货单的对应关系
  • 仓库/门店ID:负责发出商品的具体库存地点
  • 服务商ID:负责运输的物流服务商编号
  • 配送方式:具体的配送类型,如同城配送、快递配送等
  • 配送员信息:包括配送员姓名、工号和联系方式
  • 收件人姓名:订单收货人的真实姓名
  • 联系方式:手机号码、电话等
  • 收件地址:详细的配送目的地信息
  • 开始配送时间:包裹开始配送的具体时间
  • 完成配送时间:包裹成功送达的实际时间
  • 单据状态:配送单当前处理阶段,如待配送、配送中、已签收等
  • 配送费用:该配送单产生的实际运输费用

6、配送单明细

配送单明细记录每个配送单中具体的商品信息,包含商品ID、SKU_ID、数量和重量等属性。

订单履约的拆单逻辑

在整个发货环节中,订单是起点,发货单是订单拆分后的结果,用于处理更细粒度的履约逻辑。仓库收到发货单后会生成出库单,并以出库单为单位进行打包发货,如图所示。整个交易履约流程中会出现两次拆单:

1、第一次拆单

用户从购物车提交多个商品后,系统会根据业务规则将订单拆分为多个子订单,以便更好地管理和流转订单数据。子订单拆分主要基于交易结算的需求,包括不同商家主体、不同交易模式(如海外购)、不同支付结算方式、不同收货地址等因素。

2、第二次拆单

当一个子订单需要分成多个发货批次时,订单履约系统会将订单再次拆分为发货单,并将各个发货任务下发给仓库。发货单拆分主要考虑物流执行层面的因素,包括不同仓库存储、不同物流条件、不同体积和重量,以及用户的特殊配送需求等实际操作因素。

接下来,让我们详细探讨几种常见的订单拆分场景,包括单门店履约、多仓库履约、基于物流条件和商品特性的拆分方案。

每种场景都有其独特的业务需求和实现逻辑,通过深入理解这些场景,我们可以更好地设计和优化订单履约系统。

单门店履约场景

在连锁模式下,系统会根据用户的收货地址进行智能匹配,通过计算门店服务半径和距离,为用户筛选出最近且可履约的门店。如图所示,此场景下,订单不需要拆分,直接分配给匹配门店进行拣货、打包和发货。

多仓库履约场景

有些商家拥有多个仓库和门店,不同商品分散存储在各个库存点。当用户下单后,如果订单中的商品分布在不同仓库,系统会将订单拆分成多个发货单,并将每个发货单分配给对应的仓库,由各仓库根据商品数量进行备货和出库。

按物流条件拆分

由于部分商品具有特殊属性,需要特定的物流条件,因此我们需要将订单拆分成不同的发货单。

例如,生鲜水果、冷链食品和易碎物品对快递服务的保护性和时效性要求较高,必须进行单独包装和发货。当订单中包含这类商品时,系统会自动进行订单拆分。

按商品体积和重量拆分

物流公司对包裹的体积和重量都设有限制。当订单中的商品超出这些限制时,系统需要将订单拆分为多个发货单。从成本角度来看,某些情况下将大量商品分成多个小包裹发货,反而比合并成一个大包裹更经济。

本文已收录于,我的技术网站:
tangshiye.cn
里面有,AI 编程、算法 Leetcode 详解、面试八股文、BAT面试真题、简历模版、架构设计,等经验分享。

每到过节,不少小伙伴都会给自己的头像 P 个图,加点儿装饰。

比如圣诞节给自己头上 P 个圣诞帽,国庆节 P 个小红旗等等。这是一类比较简单、需求量却很大的 P 图场景,也有很多现成的网站和小程序,能帮你快速完成这件事。

这些小工具其实都是通过前端开发完成的,如果是在以前,我们想自己做个这种工具,你需要从 0 自己写代码,或者从 GitHub 上找个现成的开源项目。

但现在可是 2025 年,开发模式已经发生了巨变。利用 AI 工具,我们可以一行代码不写,制作出各种前端小网站。

首先选择一款 AI 工具,我个人日常是用 GPT 的,你也可以选择 Claude、国产 AI,在效果上有一些区别,但用法都是大同小异的。

你可以把 AI 当成一个 “真人”,你想让它做事,就必须给它传递
尽可能清晰明确的
指令,必须要让它知道自己要做什么、要达到什么目标。至于具体怎么做,你可以完全交给它。

那现在我们要开发一个 “给头像添加圣诞帽” 的小网站,就需要给 AI 提需求,比如先来个一句话需求:

请你帮我开发一个单页面网站,用户可以上传本地图片,并且自由移动圣诞帽到图片的任何一个位置,最后点击按钮可以下载【圣诞帽合成到原图上】的新图片。

AI 的回复如下,生成了一堆代码。现在 GPT 对交互做了升级,生成代码时会自动给你打开一个编辑器界面:

我们复制代码到一个新的 HTML 文件中,然后需要修改一下圣诞帽图片的地址为自己本地找的图片,因为 AI 并不知道你需要什么样的圣诞帽,网上找的图片也可能由于跨域或防盗链等原因无法正常加载。

<div id="editor-container">
   <img id="uploaded-image" src="" alt="Uploaded Image" style="display: none;">
   <img id="hat" src="你的圣诞帽图片地址" alt="Santa Hat" style="display: none;">
</div>

双击运行网站,效果如图:

丑是丑了点,但是功能已经可以正常使用了,整个过程不到 1 分钟,就完成了这款工具网站。

当然,前面也说了,在给 AI 提需求时,必须给它传递
尽可能清晰明确的
指令。它完成的效果是否符合你的预期,取决于你提需求的本领了。别拿这不当回事,程序员以后的竞争很有可能从写代码的技术转变为提需求的能力。

那我们再提一些更精确的需求:

再优化一下网站,要求如下:
1. 给整个网站增加圣诞节的氛围,可以利用 Emoji 表情、网络图片、以及样式代码实现
2. 优化页面的样式,使得图片不要太宽或太高,优化按钮的展示样式等
3. 将网站文案改为全中文,并优化表达为 “活泼” 的风格

提出新的需求后,AI 会在原有代码基础上进行修改,一会儿就给出了结果:

这次的效果明显好了很多,已经能拿去给别人用了~

你还可以在此基础上,让 AI 帮你新增图片裁切、放大缩小、旋转角度等 P 图常用的功能。

目前来说,需求的
上下文
越少,越适合使用 AI 生成。这里的 “上下文” 可以理解为一些背景信息,比如你让 AI 生成 “鱼皮的个人博客”,如果不指定上下文 “鱼皮是指程序员鱼皮这个真正的 man”,AI 可能就理解成可以吃的那种鱼皮了。

所以,目前 AI 非常适合生成纯前端的、单页面的、单个功能的应用。

当然,这不代表 AI 不能应用于复杂的项目中,在我们常用的开发工具比如 JetBrains、VS Code 中都有 AI 插件,有些插件会将你项目中的文件作为上下文,来帮你生成和优化代码。效果就是,AI 可以预判到你接下来要写什么代码,而你只需要选择是否接受 AI 的答案就好了:

像现在 AI 开发工具 Cursor、Windsurf 都非常火,它们将 AI 和代码编辑这件事深度结合,你可以直接在开发工具中通过不断地向 AI 提问,让它帮你生成一个完整的大项目,而不只是一个单页面网站:

我团队同学用 Cursor 比较多,它是支持自主选择 AI 大模型的,可以根据生成效果选择:

关于 Cursor 就不过多介绍了,只是一个工具,界面和 VS Code 还很像,下载之后摸索一下就差不多了。

通过这篇文章,希望大家能 get 到利用 AI 提效的思路吧,AI 虽然替代不了程序员,但它可以帮助我们摸鱼啊!

更多编程学习资源

一:背景

1. 讲故事

前两天给训练营里的一位学员分析了一个dump,学员因为弄了一整天也没找到祸根,被我一下子弄出来了,极度想看看我是怎么分析的?由于在微信上不能一言两语表尽,干脆写一篇文章出来详细的讲讲吧,哈哈,训练营里的学员得有求必应哈。。。话不多说,我们一起探索下这个程序的崩溃之路吧。

二:WinDbg分析

1. 为什么会崩溃

这个比较简单,因为默认会自动定位到崩溃的线程,使用
.ecxr
切到崩溃前的上下文即可,然后使用
k
观察崩溃点,参考如下:


0:044> .ecxr
rax=0000000000000000 rbx=00000217a6c78f90 rcx=0000000000000000
rdx=0000000000000000 rsi=000000242c46fdd0 rdi=00000217ff1594a0
rip=00007ffdb35eb699 rsp=000000242c46fb40 rbp=000000242c46fc70
 r8=00007ffdb35eb699  r9=00000217ea61fc08 r10=00000000e0434352
r11=0000000000000001 r12=00000217a6c78f90 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0         nv up ei pl nz na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
KERNELBASE!RaiseException+0x69:
00007ffd`b35eb699 0f1f440000      nop     dword ptr [rax+rax]
0:044> k
  *** Stack trace for last set context - .thread/.cxr resets it
 # Child-SP          RetAddr               Call Site
00 00000024`2c46fb40 00007ffd`51bf8c6f     KERNELBASE!RaiseException+0x69
01 00000024`2c46fc20 00007ffd`51890108     clr!UMThunkStubRareDisableWorker+0x3f
02 00000024`2c46fc50 00007ffc`dc826748     clr!UMThunkStub+0x128
03 00000024`2c46fce0 00007ffc`dc827406     MVCAMSDK_X64+0x6748
04 00000024`2c46fd80 00007ffc`dc9d21eb     MVCAMSDK_X64!CameraGrabber_GetCameraDevInfo+0xb96
05 00000024`2c46fe50 00007ffc`dc9d227f     MVCAMSDK_X64!CameraGigeEnumerateDevice+0x167f2b
06 00000024`2c46fe80 00007ffd`b5a07374     MVCAMSDK_X64!CameraGigeEnumerateDevice+0x167fbf
07 00000024`2c46feb0 00007ffd`b5ebcc91     kernel32!BaseThreadInitThunk+0x14
08 00000024`2c46fee0 00000000`00000000     ntdll!RtlUserThreadStart+0x21

从卦中可以看到程序是在clr 的 UMThunkStubRareDisableWorker 函数中故意抛出的异常,接下来观察下该函数代码。


    extern "C" VOID __stdcall UMThunkStubRareDisableWorker(Thread* pThread, UMEntryThunk* pUMEntryThunk, Frame* pFrame)
    {
        if (!CanRunManagedCode())
        {
            pThread->m_fPreemptiveGCDisabled = 0;
            COMPlusThrowBoot(E_PROCESS_SHUTDOWN_REENTRY);
        }
    }

    BOOL CanRunManagedCode(BOOL fCannotRunIsUserError, HINSTANCE hInst)
    {
        if (g_fForbidEnterEE == TRUE)
            return FALSE;

        if ((g_fEEShutDown & ShutDown_Finalize2) && !GCHeap::GetGCHeap()->IsCurrentThreadFinalizer())
            return FALSE;

        if (g_pPreallocatedOutOfMemoryException == NULL)
            return FALSE;

        return TRUE;
    }

从卦中的代码看是因为
CanRunManagedCode()
返回false导致的,到内存中观察这几个变量即可。


0:044> dp clr!g_fForbidEnterEE L1
00007ffd`5201d548  00000000`00000000

0:044> dp clr!g_fEEShutDown L1
00007ffd`520140b0  00000000`00000007

0:044> dp clr!g_pPreallocatedOutOfMemoryException L1
00007ffd`52013e08  00000217`e4f513d8

看到卦中的几个变量大概就知道了,原来当前程序正在等待 终结器线程 执行完毕的 EEShutDown 状态。

2. 真的处于关闭状态吗

刚才只是通过变量观察,接下来我们观察线程栈来进一步求证,分别切到主线程和终结器线程观察调用栈,参考如下:


0:000> ~0s;k;~2s;k
win32u!NtUserMsgWaitForMultipleObjectsEx+0x14:
00007ffd`b3dfa104 c3              ret
 # Child-SP          RetAddr               Call Site
00 00000024`2773f578 00007ffd`b3f40c8e     win32u!NtUserMsgWaitForMultipleObjectsEx+0x14
...
08 00000024`2773f9c0 00007ffd`518828ff     clr!CLREventBase::WaitEx+0xab
09 00000024`2773fa30 00007ffd`51882842     clr!WaitForEndOfShutdown_OneIteration+0xb3
0a 00000024`2773fac0 00007ffd`5171f703     clr!WaitForEndOfShutdown+0x1a
0b 00000024`2773faf0 00007ffd`518243f9     clr!EEShutDown+0xd3
0c 00000024`2773fb40 00007ffd`51823f34     clr!HandleExitProcessHelper+0x29
0d 00000024`2773fb70 00007ffd`51823e14     clr!_CorExeMainInternal+0xf8
0e 00000024`2773fc00 00007ffd`96d7d6ea     clr!CorExeMain+0x14
0f 00000024`2773fc40 00007ffd`96c3ac42     mscoreei!CorExeMain+0xfa
10 00000024`2773fca0 00007ffd`b5a07374     mscoree!CorExeMain_Exported+0x72
11 00000024`2773fcd0 00007ffd`b5ebcc91     kernel32!BaseThreadInitThunk+0x14
12 00000024`2773fd00 00000000`00000000     ntdll!RtlUserThreadStart+0x21

paddle_inference_c!paddle::CreatePaddlePredictor<paddle::AnalysisConfig,3>+0x180be:
00007ffc`d39a1a9e 488b01          mov     rax,qword ptr [rcx] ds:00000217`89df37b0=00007ffcd7791ff0
 # Child-SP          RetAddr               Call Site
00 00000024`27eff080 00007ffc`d39a1a8f     paddle_inference_c!paddle::CreatePaddlePredictor<paddle::AnalysisConfig,3>+0x180be
01 00000024`27eff0b0 00007ffc`d39a1a8f     paddle_inference_c!paddle::CreatePaddlePredictor<paddle::AnalysisConfig,3>+0x180af
02 00000024`27eff0e0 00007ffc`d39a1a8f     paddle_inference_c!paddle::CreatePaddlePredictor<paddle::AnalysisConfig,3>+0x180af
...
0e 00000024`27eff3b0 00007ffc`f20cbbde     paddle_inference_c!PD_PredictorDestroy+0x39
0f 00000024`27eff3e0 00007ffc`f943796a     0x00007ffc`f20cbbde
10 00000024`27eff4b0 00007ffc`f9437930     Sdcb_PaddleInference!Sdcb.PaddleInference.PaddlePredictor.Dispose+0x1a
11 00000024`27eff4e0 00007ffd`518915e6     Sdcb_PaddleInference!Sdcb.PaddleInference.PaddlePredictor.Finalize+0x10
12 00000024`27eff510 00007ffd`5173b6c6     clr!FastCallFinalizeWorker+0x6
13 00000024`27eff540 00007ffd`5173bd73     clr!FastCallFinalize+0x5a
14 00000024`27eff580 00007ffd`5173bcae     clr!MethodTable::CallFinalizer+0xb7
15 00000024`27eff5c0 00007ffd`5173b8e7     clr!CallFinalizer+0x5e
...
1e 00000024`27effa50 00000000`00000000     ntdll!RtlUserThreadStart+0x21

其实到这里逻辑全部清楚了,用户点击了关闭程序,但此时终结器线程正在执行,所以执行引擎需要等待它执行完,在这期间是禁止除终结器线程之外的
任何线程
再对托管方法的调用,一旦有就会抛出异常,画个简图如下:

3. call托管方法在哪里

在错误的时间出现了一个对的人,这是一种意难平,接下来把这个对的人给找出来,即 UMThunkStubRareDisableWorker方法的第二个参数pUMEntryThunk,因为这里面的 m_pManagedTarget 即托管方法的入口点,模型如下:


class UMEntryThunk
{
    private:
    // The start of the managed code
    const BYTE* m_pManagedTarget;

    // This is used for profiling.
    PTR_MethodDesc m_pMD;
}

那如何挖呢?观察UMThunkStubRareDisableWorker方法汇编代码即可。

0:044> k
  *** Stack trace for last set context - .thread/.cxr resets it
 # Child-SP          RetAddr               Call Site
00 00000024`2c46fb40 00007ffd`51bf8c6f     KERNELBASE!RaiseException+0x69
01 00000024`2c46fc20 00007ffd`51890108     clr!UMThunkStubRareDisableWorker+0x3f
02 00000024`2c46fc50 00007ffc`dc826748     clr!UMThunkStub+0x128
03 00000024`2c46fce0 00007ffc`dc827406     MVCAMSDK_X64+0x6748
04 00000024`2c46fd80 00007ffc`dc9d21eb     MVCAMSDK_X64!CameraGrabber_GetCameraDevInfo+0xb96
05 00000024`2c46fe50 00007ffc`dc9d227f     MVCAMSDK_X64!CameraGigeEnumerateDevice+0x167f2b
06 00000024`2c46fe80 00007ffd`b5a07374     MVCAMSDK_X64!CameraGigeEnumerateDevice+0x167fbf
07 00000024`2c46feb0 00007ffd`b5ebcc91     kernel32!BaseThreadInitThunk+0x14
08 00000024`2c46fee0 00000000`00000000     ntdll!RtlUserThreadStart+0x21

0:044> uf 00007ffd`51890108
00007ffd`5188ffe0 4154            push    r12
00007ffd`5188ffe2 55              push    rbp
00007ffd`5188ffe3 4883ec78        sub     rsp,78h
00007ffd`5188ffe7 488d6c2420      lea     rbp,[rsp+20h]
00007ffd`5188ffec c6454000        mov     byte ptr [rbp+40h],0
00007ffd`5188fff0 e8abe0ffff      call    clr!GetThread (00007ffd`5188e0a0)
00007ffd`5188fff5 4885c0          test    rax,rax
00007ffd`5188fff8 746f            je      clr!UMThunkStub+0x89 (00007ffd`51890069)  Branch
...
00007ffd`518900f9 4c895548        mov     qword ptr [rbp+48h],r10
00007ffd`518900fd 498bcc          mov     rcx,r12
00007ffd`51890100 498bd2          mov     rdx,r10
00007ffd`51890103 e8288b3600      call    clr!UMThunkStubRareDisableWorker (00007ffd`51bf8c30)

根据x64调用协定,参数是放在 r10 寄存器中,而r10刚好放在
rbp+48h
中,所以挖出这个栈位置即可,简单计算为。


0:044> ? 00000024`2c46fce0-0x8-0x8-0x8-0x78+0x20
Evaluate expression: 155361672304 = 00000024`2c46fc70

0:044> dp 00000024`2c46fc70+0x48 L1
00000024`2c46fcb8  00000217`ff1594a0

0:044> dp 00000217`ff1594a0 L1
00000217`ff1594a0  00007ffc`f2f49380

0:044> !U 00007ffc`f2f49380
Unmanaged code
00007ffc`f2f49380 e98bbd0500      jmp     00007ffc`f2fa5110
00007ffc`f2f49385 5f              pop     rdi
00007ffc`f2f49386 0f              ???
00007ffc`f2f49387 25e8e35194      and     eax,9451E3E8h
00007ffc`f2f4938c 5e              pop     rsi
00007ffc`f2f4938d 5e              pop     rsi
00007ffc`f2f4938e 1124e8          adc     dword ptr [rax+rbp*8],esp
00007ffc`f2f49391 db5194          fist    dword ptr [rcx-6Ch]
00007ffc`f2f49394 5e              pop     rsi
00007ffc`f2f49395 5e              pop     rsi

0:044> !ip2md 00007ffc`f2fa5110
MethodDesc:   00007ffcf33bd9e8
Method Name:  baslerCamera.MVCam.CameraGrabberFrameCallback(IntPtr, IntPtr, baslerCamera.tSdkFrameHead ByRef, IntPtr)
Class:        00007ffcf33c1c98
MethodTable:  00007ffcf33be828
mdToken:      0000000006000067
Module:       00007ffcf2352f60
IsJitted:     yes
CodeAddr:     00007ffcf2fa5110
Transparency: Critical

从 baslerCamera 来看是一个工业相机,接下来赶紧寻找源代码,截图如下:

图中的 m_FrameCallback 是一个delegate函数,并通过 CameraGrabber_SetRGBCallback 方法传给了C++,所以在关闭程序的时候一定要先释放掉这个 calllback 来和C++撇清关系,否则就会有灾难的降临。

三:总结

在我的dump分析之旅中曾遇到过一次相似的生产故障,本篇文章主要还是对训练营里这位朋友的有求必应吧,不过说实话,多分析几个像这样的dump,会极大的提升你的高级调试能力。
图片名称