系列文章目录
文章目录
前言
这系列的文章主要是基于扔物线的HenCoderPlus课程的源码来分析学习。
- 扔物线课程源码:CameraView.java
- Android官方文档:自定义绘制
这一篇文章主要介绍的是Camera做三维变换,更多细节请见:HenCoder Android 开发进阶:自定义 View 1-4 Canvas 对绘制的辅助。
创建绘制对象
我们需要创建一个画笔🖌Paint
来绘制我们的头像的边框,也需要提前加载我们的头像到内存(Bitmap)中。
public class CameraView extends View {
// 抗锯齿
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
public CameraView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
}
加载图片
为避免出现
java.lang.OutOfMemory
异常,请先检查位图的尺寸,然后再对其进行解码,除非您绝对信任该来源可为您提供大小可预测的图片数据,以轻松适应可用的内存。——引用自Android官方文档:高效加载大型位图
下述源码的分析请见官方的说明:将按比例缩小的版本加载到内存中。
public class CameraView extends View {
// 图片大小
private static final int IMAGE_WIDTH = 600;
// 头像的Bitmap
Bitmap bitmap;
public CameraView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
{
bitmap = Utils.getAvatar(getResources(), IMAGE_WIDTH);
}
}
public class Utils {
public static Bitmap getAvatar(Resources res, int width) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
options.inJustDecodeBounds = false;
options.inDensity = options.outWidth;
options.inTargetDensity = width;
return BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
}
}
自定义绘制内容
先上一下我们使用Camera做三维变换的效果图。
定义绘制的位置
public class CameraView extends View {
// 图片大小
private static final int IMAGE_WIDTH = 600;
// 图片绘制的偏移量(left、top)
private static final int BITMAP_OFFSET = 200;
public CameraView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
}
变换流程解析
Canvas
的几何变换顺序是反的,所以 Canvas
的位置变换 translate
、图形旋转 rotate
等都是反着写的。
变换参数
public class CameraView extends View {
// 抗锯齿
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
// 相机
Camera camera = new Camera();
// 图片大小
private static final int IMAGE_WIDTH = 600;
// 图片的大小的一半
private static final int HALF_OF_IMAGE_WIDTH = IMAGE_WIDTH / 2;
// 图片绘制的偏移量(left、top)
private static final int BITMAP_OFFSET = 200;
// Canvas移动到圆点的偏移量(dx、dy)
private static final int OFFSET = HALF_OF_IMAGE_WIDTH + BITMAP_OFFSET;
// Canvas旋转的角度
private static final int ROTATE_ANGLE = 45;
}
先放上两部分单独的绘制效果:
左上部分变换
public class CameraView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制上半部分
canvas.save();
// ... 省略 ...
// 1. 将图片的中心移动到视图的圆点位置
canvas.translate(-OFFSET, -OFFSET);
canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
canvas.restore();
}
}
3. 裁剪图像,保留x轴上方的灰色区域
public class CameraView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制上半部分
canvas.save();
// ... 省略 ...
// 3. 裁剪图像,保留x轴上方的灰色区域
canvas.clipRect(-IMAGE_WIDTH, -IMAGE_WIDTH, IMAGE_WIDTH, 0);
// 2. 图像以视图圆点为中心,旋转45°
canvas.rotate(ROTATE_ANGLE);
// 1. 将图片的中心移动到视图的圆点位置
canvas.translate(-OFFSET, -OFFSET);
canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
canvas.restore();
}
}
这里简单解释下 canvas.clipRect
入参采用的是 IMAGE_WIDTH
的原因:作者懒 计算方便……╮(╯▽╰)╭
设 IMAGE_WIDTH
为
W
W
W,那么可以得出以下几个坐标位置:
坐标点 | 坐标位置 | 对应点 | 坐标位置 |
---|---|---|---|
A | ( − 1 2 W 2 -\sqrt{\frac{1}{2}W^2} −21W2, 0 0 0) | left | ( − W -W −W, 0 0 0) |
B | ( 0 0 0, 1 2 W 2 \sqrt{\frac{1}{2}W^2} 21W2) | top | ( 0 0 0, W W W) |
C | ( 1 2 W 2 \sqrt{\frac{1}{2}W^2} 21W2, 0 0 0) | right | ( W W W, 0 0 0) |
D | ( 0 0 0, − 1 2 W 2 -\sqrt{\frac{1}{2}W^2} −21W2) | bottom | ( 0 0 0, − W -W −W) |
由上,易得: W > 1 2 W 2 W > \sqrt{\frac{1}{2}W^2} W>21W2
5. 将图像中心从视图圆点恢复到原来位置
public class CameraView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制上半部分
canvas.save();
// 5. 将图像中心从视图圆点恢复到原来位置
canvas.translate(OFFSET, OFFSET);
// 4. 图像以视图圆点为中心,旋转-45°
canvas.rotate(-ROTATE_ANGLE);
// 3. 裁剪图像,保留x轴上方的灰色区域
canvas.clipRect(-IMAGE_WIDTH, -IMAGE_WIDTH, IMAGE_WIDTH, 0);
// 2. 图像以视图圆点为中心,旋转45°
canvas.rotate(ROTATE_ANGLE);
// 1. 将图片的中心移动到视图的圆点位置
canvas.translate(-OFFSET, -OFFSET);
canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
canvas.restore();
}
}
右下部分变换
右下部分基本和上一章节一致,这里仅仅介绍不一样的地方。
3. 裁剪图像,保留x轴下方的灰色区域
public class CameraView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制上半部分
canvas.save();
// ... 省略 ...
// 3. 裁剪图像,保留x轴上方的灰色区域
canvas.clipRect(-IMAGE_WIDTH, 0, IMAGE_WIDTH, IMAGE_WIDTH);
// 2. 图像以视图圆点为中心,旋转45°
canvas.rotate(ROTATE_ANGLE);
// 1. 将图片的中心移动到视图的圆点位置
canvas.translate(-OFFSET, -OFFSET);
canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
canvas.restore();
}
}
3D变换
这里有一些API的介绍放到后面章节再讲,可以先忽略。
public class CameraView extends View {
// 相机
Camera camera = new Camera();
public CameraView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
{
bitmap = Utils.getAvatar(getResources(), IMAGE_WIDTH);
// 沿着x轴旋转45°
camera.rotateX(45);
// 设置相机的z轴位置
camera.setLocation(0, 0, Utils.getZForCamera()); // -8 = -8 * 72
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制下半部分
canvas.save();
// 6. 将图像中心从视图圆点恢复到原来位置
// canvas.translate(OFFSET, OFFSET);
// 5. 图像以视图圆点为中心,旋转-45°
// canvas.rotate(-ROTATE_ANGLE);
// 4. 应用3D变换
camera.applyToCanvas(canvas); // 关键代码
// 3. 裁剪图像,保留x轴下方的灰色区域
canvas.clipRect(-IMAGE_WIDTH, 0, IMAGE_WIDTH, IMAGE_WIDTH);
// 2. 图像以视图圆点为中心,旋转45°
canvas.rotate(ROTATE_ANGLE);
// 1. 将图片的中心移动到视图的圆点位置
canvas.translate(-OFFSET, -OFFSET);
canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
canvas.restore();
}
}
4. 应用3D变换(第二张图)
5. 图像以视图圆点为中心,旋转-45°(第三张图)
最后放下合起来的效果(第四张图)
3D变换知识点
这里贴下上面章节缺失的一个方法的定义。
- Android Developer中文网:Camera#setLocation 方法介绍。
public class CameraView extends View {
// 相机
Camera camera = new Camera();
public CameraView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
{
// 设置相机的z轴位置
camera.setLocation(0, 0, Utils.getZForCamera()); // -8 = -8 * 72
}
}
public class Utils {
public static float getZForCamera() {
return -6 * Resources.getSystem().getDisplayMetrics().density;
}
}
Camera.setLocation(x, y, z)
:设置虚拟相机的位置。- x x x、 y y y、 z z z 的单位:inche。
- 设计历史:这种设计源自 Android 底层的图像引擎
Skia
。在Skia
中,Camera
的位置单位是英寸,英寸和像素的换算单位在Skia
中被写死为了 72 像素,而Android
中把这个换算单位照搬了过来。 - 影响:在
Camera
中,相机的默认位置是 ( 0 , 0 , − 8 ) (0, 0, -8) (0,0,−8)(inche)。 8 × 72 = 576 8 \times 72 = 576 8×72=576,所以它的默认位置是 ( 0 , 0 , − 576 ) (0, 0, -576) (0,0,−576)(像素)。如果绘制的内容过大,当它翻转起来的时候,就有可能出现图像投影过大的「糊脸」效果。 - 作用:实现 UI 一致性。o( ̄▽ ̄)d
让我们来考古看看相关代码实现,╮(╯▽╰)╭。先从对应的 android.graphics.Camera#setLocation
方法开始找:
/**
* A camera instance can be used to compute 3D transformations and
* generate a matrix that can be applied, for instance, on a
* {@link Canvas}.
*/
public class Camera {
/**
* Sets the location of the camera. The default location is set at
* 0, 0, -8.
*
* @param x The x location of the camera
* @param y The y location of the camera
* @param z The z location of the camera
*/
public native void setLocation(float x, float y, float z);
}
这是一个 native
方法,我们可以通过 JNI
的特殊命名规则,找到对应的 native
实现。这里的 Camera
对应的 native
方式是:android_graphics_camera_setLocation
。让我们搜索下看看。
无奈的是,这种方式没找到,╮(╯▽╰)╭。功夫不负有心人,总算通过其他办法找到了。
// ----------------------------------------------------------------------------
/*
* JNI registration.
*/
static const JNINativeMethod gCameraMethods[] = {
/* name, signature, funcPtr */
{ "nativeConstructor", "()V", (void*)Camera_constructor },
{ "nativeDestructor", "()V", (void*)Camera_destructor },
{ "save", "()V", (void*)Camera_save },
{ "restore", "()V", (void*)Camera_restore },
{ "translate", "(FFF)V", (void*)Camera_translate },
{ "rotateX", "(F)V", (void*)Camera_rotateX },
{ "rotateY", "(F)V", (void*)Camera_rotateY },
{ "rotateZ", "(F)V", (void*)Camera_rotateZ },
{ "rotate", "(FFF)V", (void*)Camera_rotate },
// 我们在找的方法
{ "setLocation", "(FFF)V", (void*)Camera_setLocation },
{ "getLocationX", "()F", (void*)Camera_getLocationX },
{ "getLocationY", "()F", (void*)Camera_getLocationY },
{ "getLocationZ", "()F", (void*)Camera_getLocationZ },
{ "nativeGetMatrix", "(J)V", (void*)Camera_getMatrix },
{ "nativeApplyToCanvas", "(J)V", (void*)Camera_applyToCanvas },
{ "dotWithNormal", "(FFF)F", (void*)Camera_dotWithNormal }
};
int register_android_graphics_Camera(JNIEnv* env) {
jclass clazz = android::FindClassOrDie(env, "android/graphics/Camera");
gNativeInstanceFieldID = android::GetFieldIDOrDie(env, clazz, "native_instance", "J");
return android::RegisterMethodsOrDie(env, "android/graphics/Camera", gCameraMethods,
NELEM(gCameraMethods));
}
static void Camera_setLocation(JNIEnv* env, jobject obj, jfloat x, jfloat y, jfloat z)
static void Camera_setLocation(JNIEnv* env, jobject obj, jfloat x, jfloat y, jfloat z) {
jlong viewHandle = env->GetLongField(obj, gNativeInstanceFieldID);
Sk3DView* v = reinterpret_cast<Sk3DView*>(viewHandle);
v->setCameraLocation(x, y, z);
}
这样子,我们也知道了 native
对应实现的方法是 Sk3DView::setCameraLocation
。
事实上,我最早是通过 「Backport android.graphics.Camera.setLocation() functionality」 找到这个实现的。╮(╯▽╰)╭
先看看 skia/include/utils/SkCamera.h:
// DEPRECATED
class SK_API Sk3DView : SkNoncopyable {
public:
#ifdef SK_BUILD_FOR_ANDROID_FRAMEWORK
// 这里定义了 Android 使用特定函数
void setCameraLocation(SkScalar x, SkScalar y, SkScalar z);
SkScalar getCameraLocationX() const;
SkScalar getCameraLocationY() const;
SkScalar getCameraLocationZ() const;
#endif
private:
struct Rec {
Rec* fNext;
SkM44 fMatrix;
};
Rec* fRec;
Rec fInitialRec;
SkCamera3D fCamera;
};
#endif
再看看对应实现内容 skia/src/utils/SkCamera.cpp:
#ifdef SK_BUILD_FOR_ANDROID_FRAMEWORK
void Sk3DView::setCameraLocation(SkScalar x, SkScalar y, SkScalar z) {
// the camera location is passed in inches, set in pt
SkScalar lz = z * 72.0f;
fCamera.fLocation = {x * 72.0f, y * 72.0f, lz};
fCamera.fObserver = {0, 0, lz};
fCamera.update();
}
SkScalar Sk3DView::getCameraLocationX() const {
return fCamera.fLocation.x / 72.0f;
}
SkScalar Sk3DView::getCameraLocationY() const {
return fCamera.fLocation.y / 72.0f;
}
SkScalar Sk3DView::getCameraLocationZ() const {
return fCamera.fLocation.z / 72.0f;
}
#endif
看完了大致的代码,我们也通过分析源码进一步加深了对下面这句话的理解了。
- 这种设计源自 Android 底层的图像引擎
Skia
。在Skia
中,Camera
的位置单位是英寸,英寸和像素的换算单位在Skia
中被写死为了 72 像素,而Android
中把这个换算单位照搬了过来。
android.view.RenderNode
顺着 Sk3DView::setCameraLocation
翻下对应的引用,看看还能发现什么有意思的东西。
/*
* Data structure that holds the properties for a RenderNode
*/
class ANDROID_API RenderProperties {
public:
RenderProperties();
virtual ~RenderProperties();
bool setCameraDistance(float distance) {
if (distance != getCameraDistance()) {
mPrimitiveFields.mMatrixOrPivotDirty = true;
mComputedFields.mTransformCamera.setCameraLocation(0, 0, distance);
return true;
}
return false;
}
}
static jboolean android_view_RenderNode_setCameraDistance(CRITICAL_JNI_PARAMS_COMMA jlong renderNodePtr, float distance) {
return SET_AND_DIRTY(setCameraDistance, distance, RenderNode::GENERIC);
}
public class RenderNode {
/**
* <p>Sets the distance along the Z axis (orthogonal to the X/Y plane on which
* RenderNodes are drawn) from the camera to this RenderNode. The camera's distance
* affects 3D transformations, for instance rotations around the X and Y
* axis. If the rotationX or rotationY properties are changed and this view is
* large (more than half the size of the screen), it is recommended to always
* use a camera distance that's greater than the height (X axis rotation) or
* the width (Y axis rotation) of this view.</p>
*
* <p>The distance of the camera from the drawing plane can have an affect on the
* perspective distortion of the RenderNode when it is rotated around the x or y axis.
* For example, a large distance will result in a large viewing angle, and there
* will not be much perspective distortion of the view as it rotates. A short
* distance may cause much more perspective distortion upon rotation, and can
* also result in some drawing artifacts if the rotated view ends up partially
* behind the camera (which is why the recommendation is to use a distance at
* least as far as the size of the view, if the view is to be rotated.)</p>
*
* <p>The distance is expressed in pixels and must always be positive</p>
*
* @param distance The distance in pixels, must always be positive
* @see #setRotationX(float)
* @see #setRotationY(float)
* @return True if the value changed, false if the new value was the same as the previous value.
*/
public boolean setCameraDistance(
@FloatRange(from = 0.0f, to = Float.MAX_VALUE) float distance) {
if (!Float.isFinite(distance) || distance < 0.0f) {
throw new IllegalArgumentException("distance must be finite & positive, given="
+ distance);
}
// Native actually wants this to be negative not positive, so we flip it.
return nSetCameraDistance(mNativeRenderNode, -distance);
}
}
android.view.View
从 android.view.RenderNode
,我们可以找到下一个相关的实现:
public class View implements Drawable.Callback, KeyEvent.Callback,
AccessibilityEventSource {
/**
* <p>Sets the distance along the Z axis (orthogonal to the X/Y plane on which
* views are drawn) from the camera to this view. The camera's distance
* affects 3D transformations, for instance rotations around the X and Y
* axis. If the rotationX or rotationY properties are changed and this view is
* large (more than half the size of the screen), it is recommended to always
* use a camera distance that's greater than the height (X axis rotation) or
* the width (Y axis rotation) of this view.</p>
*
* <p>The distance of the camera from the view plane can have an affect on the
* perspective distortion of the view when it is rotated around the x or y axis.
* For example, a large distance will result in a large viewing angle, and there
* will not be much perspective distortion of the view as it rotates. A short
* distance may cause much more perspective distortion upon rotation, and can
* also result in some drawing artifacts if the rotated view ends up partially
* behind the camera (which is why the recommendation is to use a distance at
* least as far as the size of the view, if the view is to be rotated.)</p>
*
* <p>The distance is expressed in "depth pixels." The default distance depends
* on the screen density. For instance, on a medium density display, the
* default distance is 1280. On a high density display, the default distance
* is 1920.</p>
*
* <p>If you want to specify a distance that leads to visually consistent
* results across various densities, use the following formula:</p>
* <pre>
* float scale = context.getResources().getDisplayMetrics().density;
* view.setCameraDistance(distance * scale);
* </pre>
*
* <p>The density scale factor of a high density display is 1.5,
* and 1920 = 1280 * 1.5.</p>
*
* @param distance The distance in "depth pixels", if negative the opposite
* value is used
*
* @see #setRotationX(float)
* @see #setRotationY(float)
*/
public void setCameraDistance(float distance) {
final float dpi = mResources.getDisplayMetrics().densityDpi;
invalidateViewProperty(true, false);
mRenderNode.setCameraDistance(-Math.abs(distance) / dpi);
invalidateViewProperty(false, false);
invalidateParentIfNeededAndWasQuickRejected();
}
}
官方推荐使用UI一致性的方式:If you want to specify a distance that leads to visually consistent results across various densities, use the following formula.
float scale = context.getResources().getDisplayMetrics().density;
view.setCameraDistance(distance * scale);
这种方式和扔物线大佬推荐的方式基本一致,原理也是一样的。^_^
附录
- 高效加载大型位图
- Android Developer:自定义视图组件
- 扔物线官网:扔物线
- 很感谢大佬提供的教程和源码,才能好好系统学习下自定义UI的内容。
- rengwuxian/HenCoderPlus
- rengwuxian/HenCoderPlus3
- PracticeDraw3
源码
CameraView
public class CameraView extends View {
// 抗锯齿
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
// 相机
Camera camera = new Camera();
// 图片大小
private static final int IMAGE_WIDTH = 600;
// 图片的大小的一半
private static final int HALF_OF_IMAGE_WIDTH = IMAGE_WIDTH / 2;
// 图片绘制的偏移量(left、top)
private static final int BITMAP_OFFSET = 200;
// Canvas移动到圆点的偏移量(dx、dy)
private static final int OFFSET = HALF_OF_IMAGE_WIDTH + BITMAP_OFFSET;
// Canvas旋转的角度
private static final int ROTATE_ANGLE = 45;
Bitmap bitmap;
public CameraView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
{
bitmap = Utils.getAvatar(getResources(), IMAGE_WIDTH);
camera.rotateX(45);
camera.setLocation(0, 0, Utils.getZForCamera()); // -8 = -8 * 72
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制上半部分
canvas.save();
canvas.translate(OFFSET, OFFSET);
canvas.rotate(-ROTATE_ANGLE);
canvas.clipRect(-IMAGE_WIDTH, -IMAGE_WIDTH, IMAGE_WIDTH, 0);
canvas.rotate(ROTATE_ANGLE);
canvas.translate(-OFFSET, -OFFSET);
canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
canvas.restore();
// 绘制下半部分
canvas.save();
canvas.translate(OFFSET, OFFSET);
canvas.rotate(-ROTATE_ANGLE);
camera.applyToCanvas(canvas);
canvas.clipRect(-IMAGE_WIDTH, 0, IMAGE_WIDTH, IMAGE_WIDTH);
canvas.rotate(ROTATE_ANGLE);
canvas.translate(-OFFSET, -OFFSET);
canvas.drawBitmap(bitmap, BITMAP_OFFSET, BITMAP_OFFSET, paint);
canvas.restore();
}
}
Utils
public class Utils {
public static Bitmap getAvatar(Resources res, int width) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
options.inJustDecodeBounds = false;
options.inDensity = options.outWidth;
options.inTargetDensity = width;
return BitmapFactory.decodeResource(res, R.drawable.avatar_rengwuxian, options);
}
public static float getZForCamera() {
return -6 * Resources.getSystem().getDisplayMetrics().density;
}
}