移植一个抖音贴纸组件到Flutter

栏目: Android · 发布时间: 5年前

内容简介:大家好久不见,又有一个多月没有发文章了,所以今天发一篇来刷刷存在感。最近 Flutter 非常火,我这一个月也不断的找资料来学习 Flutter。经过一段时间的摸索,我发现现在很多资料都非常”水“。各种 Dart 入门、Flutter 入门、Flutter 资料收集,完全没有任何有趣的东西。我不想去写重复而无聊的文章,所以本篇文章会抛转引玉的探讨一些在学习和开发 Flutter 的过程中遇见的问题和解决方案。天下事有难易乎?为之,则难者亦易已!Q:Flutter 怎么学?

本文首发于微信公众号——世界上有意思的事,搬运转载请注明出处,否则将追究版权责任。交流qq群:859640274

大家好久不见,又有一个多月没有发文章了,所以今天发一篇来刷刷存在感。最近 Flutter 非常火,我这一个月也不断的找资料来学习 Flutter。经过一段时间的摸索,我发现现在很多资料都非常”水“。各种 Dart 入门、Flutter 入门、Flutter 资料收集,完全没有任何有趣的东西。我不想去写重复而无聊的文章,所以本篇文章会抛转引玉的探讨一些在学习和开发 Flutter 的过程中遇见的问题和解决方案。

阅读须知:

  • 1.WE——>WsElement、ECWS——>ElementContainerWidgetState、EAL——>ElementActionListener

本文分为以下章节,读者可按需阅读:

  • 1.Flutter之问——以 QA 的形式来阐述我对 Flutter 的看法和学习经验。
  • 2.移植一个Flutter控件——将仿写抖音的贴纸控件移植到 Flutter 中。
  • 3.Flutter探究——聊一聊 Flutter 的原理。
  • 4.尾巴

一、Flutter之问

天下事有难易乎?为之,则难者亦易已!

Q:Flutter 怎么学?

A:这是老生常谈的问题了。随便打开一个 Flutter 系列文章,都会为你铺平接下来几周的路。但是几周之后呢?似乎很少文章会接着写下去,**毕竟大脑最喜欢简单的东西(我也不例外),一件事情的难度与受欢迎程度成反比。**所以 Flutter 怎么学?所谓:取乎其上,得乎其中。我只有一句话: 以让 Flutter 成为你最拿手技能为目标去学。

Q:能给一些 Flutter 的学习资料吗?

A:我列举一下我学习 Flutter 过程中用到的资料:

  • 1.Dart官网,啃完官方文档,Dart 你就入门了。

  • 2.Flutter实战,这本开源书的例子很多,全部敲一遍 flutter 你就入门了。特别是最后的 Flutter 原理分析可以仔细看看。

  • 3. Flutter github 仓库 ,现在网络上 Flutter 原理分析的文章真的非常少,所以真想要成为 Flutter 专家,你必须作为开拓者去阅读 Flutter 在各种层级下的源码。

Q:Flutter 会干掉 Native?

A:Flutter 是 Native 的子集。在手机被”革命“之前,但凡业务比较复杂的公司,只会要求 Native 工程师掌握 Flutter。而不会出现抛弃 Native 只做 Flutter 的工程师,因为 Flutter 说一千道一万只是一个 ui 框架。毕竟它自身的复杂度很难支撑起比它还复杂的业务。 以上只是个人观点,有分歧可以在评论区探讨

Q:Flutter 哪些地方做的比 Native 好?

A:下面是我总结出来的 Flutter 比 Native 好的地方:

  • 1.ios、android 一把抓,还可能带上 web、mac、pc。
  • 2.Dart 语言非常现代,比 java 、oc 好上太多。
  • 3.新兴框架没有历史包袱。
  • 4.热更技术非常诱人。
  • 5.入门很简单。

二、移植一个FluTter控件

经常读我的文章的读者应该看过我上一篇文章: 抖音、ins、微信功能大比拼——Story的贴纸文字 ,这篇文章中详细比较了各家 Story 的贴纸文字的功能,然后在 Android 端实现了一个贴纸框架。而这一章我就打算将这个贴纸框架移植到 Flutter,相信最后的还原度会超过你的想象。接下来建议配合源码阅读文章。 注意这一章的大部分内容和上一篇文章中讲解 Android 端实现控件的章节是差不多的。

github 地址

使用方式:sticker_framework: ^0.0.1

1.架构方式

我们第一节先讲讲文字贴纸控件的架构实现,我会基于下面的 图1 和 github 上的代码进行讲解。建议大家把代码 clone 下来, 当然别忘了给个 star。

移植一个抖音贴纸组件到Flutter

我们先来根据图1来讲讲整个控件的架构

  • 1.我们先从整体来看:
    • 1.我们需要选择一个 StatefulWidget 作为基本的容器。所以图中的 ElementContainerWidgetState 就是一个构造这样的容器的 State,简单概括一下它有这些功能:
      • 1.处理各种手势事件,这里的手势包括单指和双指。
      • 2.添加和删除一些子 Widget。这里的子 Widget 用于绘制各种元素。
      • 3.提供一些 api 让外部能操控元素。
      • 4.提供一个 listener,让外部能够监听内部的各种流程。
    • 2.有了绘制容器,我们需要向绘制容器里面添加 Widget。而 Widget 在用户操作的过程中需要有各种数据,所以这里我用了 WE 来封装需要展示的 Widget,其内部有下面这些东西:
      • 1.各种用户操作过程中需要的数据例如:scale、rotate、x、y等等。
      • 2.有一些方法能够通过数据来更新 Widget。
      • 3.提供一些 api 让 ECWS 能更新 WE 里面的数据 。
    • 3.由 ECWS 和 WE 就能继续继承出各种各样的扩展控件。
  • 2.整体讲完了,我们就可以来仔细的讲讲图中的流程
    • 1.先讲横着的箭头: 外部/内部调用 ,外部需要调用 ECWS 来进行对 WE 的增删改查等操作时会进入这个路径,这个路径里可以有下面这些操作:
      • 1.addElement:向 ECWS 中添加一个元素。
      • 2.deleteElement:从 ECWS 中删除一个元素。
      • 3.update:让 WE 根据当前数构建出一个 Widget。
      • 4.findElementByPosition:找到传入的坐标下的最顶层的 WE。
      • 5.selectElement:选中一个 WE 且将其调到最顶层。
      • 6.unSelectElement:取消选中一个 WE。
    • 2.再来讲竖着的箭头: 手势事件流 ,这里中间会经历一些内部逻辑我们后面来讲,最终事件流会触发下面的一系列行为:
      • 1.单指移动的整个流程:当我们选中了一个 WE 的时候就可以对它进行移动。这里移动可以分为开始、进行中、结束。每个事件都会调用 WE 的对应方法以更新其内部数据。
      • 2.双指旋转缩放的整个流程:当我们选中了一个 WE 的时候可以用双指对它进行缩放和旋转。这里可以分为开始、进行中、结束。这里也会调用 WE 的对应方法更新数据。
      • 3.选中元素再次点击:当我们选中了一个 WE 的时候,可以对其再次点击。
      • 4.点击空白区域:当我们没有点击任意 WE 的时候可以进行一些操作,例如清除当前 WE 的选中状态。这个行为是可以继承的,可以交由子类来覆写。
      • 5.子类事件:我们看上面其实感觉触发的事件比较少。所以在 down、move、up 的时候会优先调用三个方法 downSelectTapOtherAction、scrollSelectTapOtherAction、upSelectTapOtherAction。这三个方法可以被子类覆写,如果返回 true 的话表示事件已经消耗了,ECWS 就不会再触发其他事件。 这样一来子类也可以对手势进行扩展,例如按住某个地方单指缩放等等。
      • 7.我图中 ECWS 也实现了一个子类 DECWS,这个类简单的加两个手势:
        • 1.单指移动缩放:类似抖音的随拍,按住元素的右下角的时候可以用拖动来对元素进行缩放和旋转。
        • 2.删除:类似抖音的随拍,点击元素左上角的时候可以直接删除元素。
    • 3.图1中有一个特性其实没有画出来因为画不下了, 那就是:ECWS 在1和2中的几乎所有行为都能被外部监听,ElementActionListener 就是负责监听的接口。ECWS 中存有一个 EAL 的 set 集合所以监听器可以添加多个。

2.技术点实现

我在开发整个控件的时候遇到过比较多的技术实现上的难点,所以这一节就选一些来讲讲,让读者在看源码的时候不会特别困惑。

(1).定义数据结构与绘制坐标系

-----代码块1----- ws_element.dart

int mZIndex = -1; // 图像的层级

  double mMoveX = 0.0; // 初始化后相对 ElementContainerWidget 中心的移动距离

  double mMoveY = 0.0; // 初始化后相对 ElementContainerWidget 中心的移动距离

  double mOriginWidth; // 初始化时内容的宽度

  double mOriginHeight; // 初始化时内容的高度

  Rect mEditRect; // 可绘制的区域

  double mRotate = 0.0; // 图像顺时针旋转的角度,以 π 为基准

  double mScale = 1.0; // 图像缩放的大小

  double mAlpha = 1.0; // 图像的透明度

  bool mIsSelected = false; // 是否处于选中状态

  bool mIsSingeFingerMove = false; // 是否处于单指移动的状态

  bool mIsDoubleFingerScaleAndRotate = false; // 是否处于双指旋转缩放的状态

  Widget mElementShowingWidget; // 展示内容的 widget

  Offset mOffset; // ElementContainerWidget 相对屏幕的位移
复制代码

函数未动数据先行,数据结构是一个框架非常核心的东西,定义了一个好的数据结构可以省去很多不必要的代码。所以这一小节我们来根据代码块1定义一下数据结构和 Widget 绘制坐标系

  • 1.我们将 WE 所在的 ECWS 作为 WE 中 view 的可绘制区域,代码块1中的 mEditRect 就是这个区域代表的矩形。所以 mEditRect 一般为**[0, 0, ECWS.getWidth, ECWS.getHeight] ,mEditRect 的单位为 px**。

  • 2.我们定义的坐标系原点在 mEditRect 的中心点,也就是 ECWS 的中心点。mMoveX、mMoveY 分别表示 view 距离坐标系原点的距离。因为它们俩默认为 0,所以一般 view 被添加到 ECWS 中的时候默认位置就在 ECWS 的中心。这两个参数的单位为 px

  • 3.我们的坐标系具有 z 轴,mZIndex 就是 z 轴的坐标,z 轴表示 view 的层叠关系,mZIndex 为 0 时表示 view 在 ECWS 的顶层。mZindex 默认为 -1,表示 view 没有被添加到 ECWS 中。mZIndex 是 整数

  • 4.我们定义 mRotate 为正时 view 顺时针转动,mRotate 的区间为[-360,360]。

    5.我们定义 view 没有缩放的时候 mScale 为 1,mScale 为 2 的时候表示 view 放大 2 倍,以此类推。

  • 6.mOriginWidth 和 mOriginHeight 为 view 的初始大小,单位是 px

  • 7.mAlpha 为 view 的透明度,默认为 1 且小于等于1。

  • 8.剩下的参数就不用解释了,代码里面都有注释。

(2).WE是如何刷新元素的

-----代码块2----- ws_element.dart
    
  add() {
    mElementShowingWidget = initWidget();
  }

  Widget initWidget();

  Widget buildTransform() {
    Matrix4 matrix4 = Matrix4.translationValues(mMoveX, mMoveY, 0);
    matrix4.rotateZ(mRotate);
    matrix4.scale(mScale, mScale, 1);
    return Transform(
      alignment: Alignment.center,
      transform: matrix4,
      child: Opacity(
        opacity: mAlpha,
        child: mElementShowingWidget,
      ),
    );
  }
复制代码
  • 1.刷新元素的核心代码就是代码块2:
    • 1.首先在 ECWS 添加一个 WE 的时候,WE 的子类中可以通过实现 initWidget() 来初始化自己需要的元素内容
    • 2.然后每次数据更新时,我们会通过 buildTransform() 构建一个 Widget 给外部使用。
    • 3.而 buildTransfrom 内部则是通过 Matrix4 和 Transform 来实现移动旋转缩放,通过 Opacity 来进行 Alpha 变换。

(3).ECWS如何构建整个容器

-----代码块2----- element_container_widget.dart

@override
  Widget build(BuildContext context) {
    RawGestureDetector gestureDetectorTwo = GestureDetector(
      child: GestureDetector(
        child: Stack(
            alignment: AlignmentDirectional.center,
            key: globalKey,
            children: mElementList.map((e) {
              return e.buildTransform();
            })
                .toList()
                .reversed
                .toList()
        ),
        onPanUpdate: onMove,
        behavior: HitTestBehavior.opaque,
      ),
    ).build(context);
    gestureDetectorTwo.gestures[RotateScaleGestureRecognizer] =
        GestureRecognizerFactoryWithHandlers<RotateScaleGestureRecognizer>(
              () => RotateScaleGestureRecognizer(debugOwner: this),
              (RotateScaleGestureRecognizer instance) {
            instance
              ..onStart = onDoubleFingerScaleAndRotateStart
              ..onUpdate = onDoubleFingerScaleAndRotateProcess
              ..onEnd = onDoubleFingerScaleAndRotateEnd;
          },
        );
    return Listener(
      child: ConstrainedBox(
        constraints: BoxConstraints(
          minHeight: double.infinity,
          minWidth: double.infinity,
        ),
        child: gestureDetectorTwo,
      ),
      behavior: HitTestBehavior.opaque,
      onPointerDown: onDown,
      onPointerUp: onUp,
    );
  }

复制代码
  • 1.我们都知道 State 中需要在 build() 中返回一个 Widget 给 StatefulWidget。
  • 2.为了装下多个有层叠关系的元素,我们使用 Stack 作为元素的容器。
  • 3.Stack 外面包装了 GestureDetector 来处理 move 事件。
  • 4.GestureDetector 外部包装了我自定义的 RotateScaleGestureRecognizer 来处理双指旋转缩放事件。
  • 5.最外层则是用 Listener 来监听手指 down 和 up 事件。
  • 6.上面这样的设计的原因我会在后面深入 Flutter 的时候讲解。

3.源码流程解析

这一节我主要会对项目中的测试 demo 进行源码流程分析,让读者对控件整体的运行方式有个简单的了解。这一节主要是讲解源码,所以读者一定要去 clone 源码,跟随文章的脚步前进。

(1).添加元素

  • 1.简单的初始化动作我就不赘述了,我们从 main.dart 的 add 按钮开始。点击后先会创建一个 StickerElement 这个是我测试用的元素,里面代码很简单也不说了。
  • 2. addSelectAndUpdateElement 是一个组合方法,里面调用了 addElementselectElementupdate ,也就是添加元素,选中元素,更新元素。我们一个个来分析::
    • 1. addElement :这个方法里主要做了下面这些事情:
      • 1.进行数据检查,如果被添加的 WE 为空或者该 WE 已经在 ECWS 中,那么添加失败。
      • 2.在 ECWS 中我维持了一个 WE 的 List,所有的 WE 都存于其中,每次 add 的时候 WE 都会被添加到 list 的最前面 ,其他 WE 的 mZIndex 也会顺势更新。
      • 3.调用 WE.add 方法,里面使用 initWidget 初始化了 mElementShowingView,前面我们说过了 initWidget 的逻辑由子类定义。
      • 4.调用监听器的对应方法,且调用自动取消选中的方法( ECWS 可以被外部决定是否自动取消选中 )。
    • 2. selectElement :WE 被 add 了之后,我们这里直接将其选中,代码里面主要做了下面这些事情:
      • 1.进行数据检查,如果需要选中的 WE 没有被添加到 ECWS 中则选中失败。
      • 2.将需要选中的 WE 从 list 中移除然后添加到 list 的顶部,然后顺便更新其他 WE 的 mZIndex。
      • 3.调用 WE 的 select 方法,里面主要就是更新要选中的 WE 的数据。
      • 4.调用监听器对应的方法。
    • 3. update :前面都做好了,就需要将 WE 调整到其应该的状态,这里我想大家都猜到了就是调用 setState 然后其会触发我们在第二节中说的 build 方法,然后调用每个 WE 的 buildTransform 返回数据被更新后的 Widget。

(2).元素单指手势

元素手势不像添加元素那样需要外部调用,元素手势是通过事件分发触发的,我们这里不讲 Flutter 的事件分发机制,只讲我们基于其上的逻辑。

  • 1.对于元素单指手势的处理,主要看三个触摸事件:down、move、up。所以我们直接看 ECWS.build 中设置的三个回调方法。
    • 1. onDown 里面的逻辑如下:
      • 1.通过 findElementByPosition 根据 down 的位置找到当前位置下最顶层的 WE。
      • 2.如果当前有选中的 WE 且与当前触摸 WE 是同一个的话,那么先调用 downSelectTapOtherAction ,这个函数可以被子类覆写,默认返回 false。也就是说子类可以优先处理当前事件,如果子类处理了这个事件,那么 return。如果子类不处理,那么将 mMode 标记为 SELECTED_CLICK_OR_MOVE ,表示最终的手势可能是点击元素,也可能是移动元素。具体的行为需要 move 或者 up 的时候才能判定。
      • 3.如果当前有选中的 WE 但与当前触摸的 WE 不是同一个的时候也分两种情况:一种情况是触摸的 WE 不存在,此时表示将 mMode 标记为 SINGLE_TAP_BLANK_SCREEN 表示 点击了 ECWS 的空白区域 。另一种情况是触摸的 WE 存在,此时表示重新选中了一个 WE。
      • 4.如果当前没有选中的 WE,也会有两种情况:一个是触摸的 WE 也不存在,那么和前面一样表示点击空白区域。否则的话就是选中一个 WE。
    • 2. onMove 中会优先将 move 事件交给 scrollSelectTapOtherAction ,该方法也可以被子类覆写,同样默认返回 false,如果子类处理了这个事件,那么就直接 return 了。否则当 mModeSELECTED_CLICK_OR_MOVE(已经选中了 WE 开始移动)、SELECT(没有选中 WE 开始移动)、MOVE(WE 移动过程中) 三种情况中的一种的时候,都可以触发移动手势。具体的逻辑在 singleFingerMove 中:
      • 1.先根据 mMode 的状态,调用 singleFingerMoveStartsingleFingerMoveProcess 。singleFingerMoveStart 中调用了监听器和 WE 的对应方法,里面基本没什么逻辑。 singleFingerMoveProcess 中也调用了监听和 WE 的对应方法,但是 WE 的对应方法中更新了 mMoveX 和 mMoveY 的数据。
      • 2.调用 update 更新 WE 中的 view。将 mMode 设置为 MOVE ,表示处于移动中。
    • 3. onUp 方法:
      • 1. mModeSELECTED_CLICK_OR_MOVE ,到这里的时候才能确认,用户的行为是 选中了元素之后的点击 ,我们在前面分析过了这里面的事件分发的机制,这里也不赘述了。
      • 2. mModeSINGLE_TAP_BLANK_SCREEN ,表示点击 ECWS 的空白处,这里调用的 onClickBlank 也是可以被子类覆写的,可以实现一些自己的逻辑。
      • 3. mModeMOVE ,结束调用单指移动结束。

三、Flutter探究

这一章我会从一个 Android 工程师的角度来研究一下 Flutter,讲一讲我在移植控件时遇见的问题们。

1.Flutter与Android对比

先看看 Flutter 与 Android 写的 App 实际的比较吧

移植一个抖音贴纸组件到Flutter
  • 1.我在将代码从 Android 移植到 Flutter 上花费了大概 10 个小时。整个控件在 Android 上开始设计到开发完成则是花费了 100 多个小时。所以整个库的移植成本并不算太高。
  • 2.看上面 gif 的比较,可以发现流畅度上面并没有区别。我找了几个朋友实际体验了一下,大家都同样没有发现使用起来有差异。
  • 3.图3、图4分别是 Flutter 和 Android 的性能图。我们发现的确像很多测评文章里面说到的。Flutter 的内存消耗要比 Native 多。在实验比较的时候我添加了几十个元素。最后两端都稳定在了一个内存数值上面。Flutter 是 256MB 左右,Android 是 128MB 左右。
移植一个抖音贴纸组件到Flutter
移植一个抖音贴纸组件到Flutter
  • 4.在移植代码的过程中,我总结了下面这些写 Java 和 Dart 之间的区别:
    • 1.Dart 有非常多的语法糖,代码比起 java 来说有比较多的精简。
    • 2.Dart 的传参方式使得写 Flutter 控件的时候更像是在写属性配置表。

2.Flutter原理

以一个 Android 工程师的眼光来看 Flutter

(1).Flutter的事件简单总结

  • 1.LIstener 是手势的基础:GestureDetector 是基于 Listener 开发的。

  • 2.事件自底向上,事件不可截断

    • 1.先定义一下:自底向上表示从子 view 到父 view。自顶向下表示从父 view 到子 view。
    • 2.做过 Android 的同学知道 Android 中的事件**是一个自顶向下再自底向上的过程。**在中间的任意一环我们都可以进行拦截,从而让事件不再继续传递。
    • 3.Flutter 的事件模型则是: 自底向上,而且目前来看没有任何操作能阻断这个流程。
    • 4.也就是说,如果我们使用 Listener 对任意一个 Widget 进行监听,那么我们在事件传递的过程中阻止 Listener 获取事件。
    • 5.事件不可截断的特性在开发中最有用的地方就是:如果我们使用 tapUp,tapDown,这类手势想要监听手指的抬起和放下,那么这些手势可能会被其他手势给冲掉。此时我们就能使用 Listener 来通过监听具体的 down 和 up 事件,因为这个是不可截断的。
  • 3.开发中我们使用 GestureDetector 封装 Widget,我们定义的一个个手势回调会让 GestureDetector 生成多个 GestureRecognizer 附着在当前的 Widget 上以处理 Widget 接收到的事件。

  • 4.每根手指的 down、move、up 都是一个事件流,当 down 事件自底向上确立了一个 Widget 链的时候,附着在链中各个 Widget 上的 GestureRecognizer 们就会去竞争这个事件流的归属。

  • 5.一个事件流的胜出 GestureRecognizer 只有一个,胜出后整个事件流都属于这个 GestureRecognizer 。

  • 6.GestureRecognizer 的胜出机制,就是 Flutter 在事件不可截断这个 feature 上的补充的灵活性,可以使得某个 Widget 上的手势被截断, 推荐优先使用 Gesture

  • 7.Gesture 的胜出机制是怎么样的?

    • 1.如果一次竞争中只有一个 GestureRecognizer,那么他就直接胜出。
    • 2.如果一次竞争中有多个相同的 GestureRecognizer,那么越底层的越胜出。
    • 3.如果一次竞争中有不同的 GestureRecognizer:
      • 1.GestureRecognizer 中定义了一个超时机制,有些 GestureRecognizer 定义了某个事件进行了一个时间阈值后如果没有其他 GestureRecognizer 申请延长阈值那么本 GestureRecognizer 就直接胜出。例如:TapGestureRecognizer 定义了 down 事件进行了 100 ms 之后,如果没有其他 GestureRecognizer 延长阈值,那么自己就获得事件流。
      • 2.而 LongPressGestureRecognizer 定义的时间阈值是 500ms,如果 500ms 后没有其他 GestureRecognizer 申请延长阈值则自己获得事件流。
      • 3.那么 TapGestureRecognizer 和 LongPressGestureRecognizer 都在的时候,通过 down 事件的长短来判断谁胜出。

(2).Flutter的绘制逻辑

Flutter 核心原理

四、尾巴

啊!感觉这篇文章有点虎头蛇尾的感觉,文章从开始到结束跨了好几周。中间又是加班又是搬家,把我的热血都消磨了。本来多加一些 Flutter 的深入探究的,但是感觉会越写越久,所以先就这样。接下来我会写一系列文章来分析 Flutter 的原理和 Flutter Sdk。所以更多内容敬请期待!ps: 一鼓作气,再而竭,三而衰。真是完美的表现了我写这篇文章的过程,希望读者们不要学我。

连载文章

不贩卖焦虑,也不标题党。分享一些这个世界上有意思的事情。题材包括且不限于:科幻、科学、科技、互联网、 程序员 、计算机编程。下面是我的微信公众号: 世界上有意思的事 ,干货多多等你来看。

移植一个抖音贴纸组件到Flutter

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

精通EJB

精通EJB

罗曼 / 第1版 (2005年9月1日) / 2005-9 / 69.0

本书是EJB组件技术教程,专注于EJB的概念、方法、开发过程的介绍。全书共分为4个部分,首先对EJB编程基础进行介绍,其次重点关注EJB编程的具体内容和过程,然后对高级EJB进行了阐述,最后的附录收集了EJB组件技术相关的其他内容。作为一本交互性好、读起来有趣、涉及到EJB中各方面知识的书籍,本书确信这正是你所寻找的。  本书是关于EJB 2.1的经典书籍,是EJB开发者必备的参考书。全书共分为3......一起来看看 《精通EJB》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

html转js在线工具
html转js在线工具

html转js在线工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具