注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

autolog.js:一个小而美的toast插件。

web
前言 最近需要做一个关于自动解析矢量瓦片链接地址的内部Demo,这个demo比较简单,所以没有准备引入任何的第三方UI库,所以遇到了一个小问题,toast提示怎么做? 如果像往常一样,我肯定直接用 alert 了,但是一是 alert 会中断体验,不够友好,二...
继续阅读 »

前言


最近需要做一个关于自动解析矢量瓦片链接地址的内部Demo,这个demo比较简单,所以没有准备引入任何的第三方UI库,所以遇到了一个小问题,toast提示怎么做?


如果像往常一样,我肯定直接用 alert 了,但是一是 alert 会中断体验,不够友好,二是不适用于多个提示共同出现,三是无法区分提示类型,所以我就想着找一个体积小的三方库来实现,但是找来找去,发现没有一个库能入我法眼。


在网上搜索,好像独立的 toast 插件停留在了 jq 时代,靠前的 toast 库居然是 bootstrap 的。所以我决定自己写一个,又小巧,又易用的 toast 插件。


纯 JS 实现


延续 autofit.js 的传统,我依然准备用纯 js 实现,以达到极致的体积、极致的兼容性。此外,还编写了d.ts,支持TS。


autolog.js 诞生了。


image.png


它由两部分构成,一个极简单的js,和一个极简单的css。gzip后体积是1.40kb。


在线体验:larryzhu-dev.github.io/autoLarryPa…


js部分(共37行)


const autolog = {
log(text, type = "log", time = 2500) {
if (typeof type === "number") {
time = type;
type = "log";
}
let mainEl = getMainElement();
let el = document.createElement("span");
el.className = `autolog-${type}`;
el.innerHTML = text;
mainEl.appendChild(el);
setTimeout(() => {
el.classList.add("hide");
}, time - 500);
setTimeout(() => {
mainEl.removeChild(el);
el = null;
}, time);
},
};
function getMainElement() {
let mainEl = document.querySelector("#autolog");
if (!mainEl) {
mainEl = document.createElement("div");
mainEl.id = "autolog";
document.body.appendChild(mainEl);
}
return mainEl;
}
export default autolog;


以上是 autolog.js的全部 js 代码。可以看到只导出了一个 log 方法,而调用此方法,也只需要必填一个参数。


我来讲一下这段代码干了一件什么事



  1. 因为有两个可选参数,所以第一步判断一下传了哪个可选参数,这可以在使用时,只传time或者type。

  2. 获取主容器,getMainElement 方法返回一个主容器,若主容器不存在,就创建它,这省去了用户手动创建主容器的过程,一般的插件会导出一个 init 方法初始化,这一步可以省去 init 操作。

  3. 创建一个 span 标签用于展示 log 内容。

  4. 两个定时器,第一个在清除元素的前 0.5 秒为其添加退场动画,第二个清除元素,el = null 可以保证断开引用,防止产生游离dom,防止内存泄漏。


最重要的在于css部分,css承载了最重要的显示逻辑。


css部分(共100行)


@font-face {
font-family: "iconfont"; /* Project id 4507845 */
src: url("//at.alicdn.com/t/c/font_4507845_4ys40xqhy9u.woff2?t=1713154951707")
format("woff2"),
url("//at.alicdn.com/t/c/font_4507845_4ys40xqhy9u.woff?t=1713154951707")
format("woff"),
url("//at.alicdn.com/t/c/font_4507845_4ys40xqhy9u.ttf?t=1713154951707")
format("truetype");
}
#autolog {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
pointer-events: none;
width: 100vw;
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 9999999;
cursor: pointer;
transition: 0.2s;
}
#autolog span {
pointer-events: auto;
width: max-content;
animation: fadein 0.4s;
animation-delay: 0s;
border-radius: 6px;
padding: 10px 20px;
box-shadow: 0 0 10px 6px rgba(0, 0, 0, 0.1);
margin: 4px;
transition: 0.2s;
z-index: 9999999;
font-size: 14px;
height: max-content;
background-color: #fafafa;
color: #333;
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#autolog span::before {
padding-right: 4px;
}
#autolog span.autolog-warn,
#autolog span.autolog-warning {
background-color: #fffaec;
color: #e29505;
}
#autolog span.autolog-warn::before,
#autolog span.autolog-warning::before {
content: "\e682";
}
#autolog span.autolog-error {
background-color: #fde7e7;
color: #d93025;
}
#autolog span.autolog-error::before {
content: "\e66f";
}
#autolog span.autolog-info {
background-color: #e6f7ff;
color: #0e6eb8;
}
#autolog span.autolog-info::before {
content: "\e668";
}
#autolog span.autolog-success,
#autolog span.autolog-ok,
#autolog span.autolog-done {
background-color: #e9f7e7;
color: #1a9e2c;
}
#autolog span.autolog-success::before,
#autolog span.autolog-ok::before,
#autolog span.autolog-done::before {
content: "\e67f";
}
#autolog span.hide {
opacity: 0;
pointer-events: none;
transform: translateY(-10px);
height: 0;
padding: 0;
margin: 0;
}
@keyframes fadein {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

css部分正正好好 100 行代码,从上到下分别是:iconfont 字体图标链接、主容器样式、各类型提示框的样式、退场类,入场动画。


由此可见,你也可以重写这些css,为他们添加不同的 icon、颜色。


没有什么巧妙的设计,也没有什么精致的构思,朴实无华的一百多行代码而已,希望这些代码可以帮到各位。


安装和使用


使用也非常简单,只需引入两个文件。


安装


npm i autolog.js

引入css(引入一次即可)


在js中引入


import 'autolog.js/autolog.css'

在css中引入


@import url('autolog.js/autolog.css');

使用


import aotolog from "autolog.js";

autolog.log("Hi,this is a normal tip");
autolog.log("Hello World", "success", 2500);
// 其中 "success" 和 2500 都是可选项

Github Link:github.com/LarryZhu-de…


NPM Link:http://www.npmjs.com/package/aut…


效果图


QQ2024417-122454.webp


在线体验:larryzhu-dev.github.io/autoLarryPa…


作者:德莱厄斯
来源:juejin.cn/post/7358598695267008527
收起阅读 »

这个交互式个人博客能让你眼前一亮✨👀 ?

web
从构思到上线的全过程,开发中遇到一些未知问题,也都通过查阅资料和源码一一解决,小记一下望对正在使用或即将使用Nextjs开发的你们有所帮助。 那些年我开发过的博客 就挺有意思,域名,技术栈和平台的折腾史 2018年使用hexo搭建了个静态博客,部署在gith...
继续阅读 »

2023-08-15 13.21.03.gif


从构思到上线的全过程,开发中遇到一些未知问题,也都通过查阅资料和源码一一解决,小记一下望对正在使用或即将使用Nextjs开发的你们有所帮助。


那些年我开发过的博客


就挺有意思,域名,技术栈和平台的折腾史



  • 2018年使用hexo搭建了个静态博客,部署在github pages

  • 2020年重新写了博客,vuenodejsmongodb三件套,使用nginx部署在云服务器上

  • 2023年云服务器过期了,再一次重写了博客,nextjs为基础框架,部署在vercel


背景


因为日常开发离不开终端,正好也有重写博客的想法,打算开发一个不只是看的博客网站,所以模仿终端风格开发了Yucihent


技术栈


nextjs 更多技术栈


选用nextjs是因为next13更新且稳定了App Router和一些其他新特性。


设计


简约为主,首页为类终端风格,prompt样式参考了starship,也参考过ohmyzsh themes,选用starship因为觉得更好看。


交互


通过手动输入或点击列出的命令进行交互,目前可交互的命令有:



  • help 查看更多

  • listls 列出可用命令

  • clear 清空所有输出

  • posts 列出所有文章

  • about 关于我


后续会新增一些命令,增加交互的趣味。


暗黑模式



基于tailwinddark modenext-themes



首先将tailwinddark mode设置为class,目的是将暗黑模式的切换设置为手动,而不是跟随系统。


// tailwind.config.js

module.exports = {
darkMode: 'class'
}

新建ThemeProvider组件,用到next-themes提供的ThemeProvider,需要在文件顶部使用use client,因为createContext只在客户端组件使用。


'use client'

import { ThemeProvider as NextThemeProvider } from 'next-themes'
import type { ThemeProviderProps } from 'next-themes/dist/types'

export default function ThemeProvider({
children,
...props
}: ThemeProviderProps
) {
return <NextThemeProvider {...props}>{children}</NextThemeProvider>
}

app/layout.tsx中使用ThemeProvider,设置attributeclass,这是必要的。


<ThemeProvider attribute="class">{children}</ThemeProvider>

next-themes提供了useTheme,解构出themesetTheme用于手动设置主题。


综上基本实现暗黑模式切换,但你会在控制台看到此报错信息:Warning: Extra attributes from the server: class,style,虽然它并不影响功能,但终究是个报错。
作为第三方包,可能存在水合不匹配的问题,经查阅资料,禁用ThemeProvider组件预渲染消除报错。


资料:



const NoSSRThemeProvider =
dynamic(() => import('@/components/ThemeProvider'), {
ssr: false
})

<NoSSRThemeProvider attribute="class">{children}</NoSSRThemeProvider>

类终端



由输入和输出组件组成,输入的结果添加到输出list中



命令输入的打字效果


Alt Text

定义打字间隔100ms,对键入的命令for处理,定时器中根据遍历的索引延迟赋值。


const autoTyping = (cmd: string) => {
const interval = 100 // ms
for (let i = 0; i < cmd.length; i++) {
setTimeout(
() => {
setCmd((prev) => prev + cmd.charAt(i))
},
interval * (i + 1)
)
}
}

滚动到底部


定义外层容器refcontainerRef,键入命令后都自动滚动到页面底部,使用了scrollIntoViewapi,作用是让调用这个api的容器始终在页面可见,block参数设置为end表示垂直方向末端对其即最底端。


const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
containerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
}, [typedCmds])

MDX



何为mdx?即给md添加了jsx支持,功能更强大的md,在nextjs中通过@next/mdx解析.mdx文件,它会将mdreact components转成html



安装相关包,后两者作为@next/mdxpeerDependencies



  • @next/mdx

  • @mdx-js/loader

  • @mdx-js/react


next.config.js新增createMDX配置


// next.config.js

import createMDX from '@next/mdx'

const nextConfig = {}

const withMDX = createMDX()
export default withMDX(nextConfig)

接着在应用根目录下新建mdx-components.tsx


// mdx-components.tsx

import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components
}
}

app目录下使用.mdx文件,useMDXComponents组件是必要的,


需要注意的是此文件命名上有一定规范只能命名为mdx-components,不能为其他名称,也不可为MdxComponents,从@next/mdx源码中可以看出会去应用根目录查找mdx-components


// @next/mdx部分源码

config.resolve.alias['next-mdx-import-source-file'] = [
'private-next-root-dir/src/mdx-components',
'private-next-root-dir/mdx-components',
'@mdx-js/react'
]

至此就可以在app中使用mdx


排版



为mdx解析成的html添加样式



解析mdx为html,但并没有样式,所以我们借助@tailwindcss/typography来为其添加样式,在tailwind.config.js使用该插件。


// tailwind.config.js

module.exports = {
plugins: [require('@tailwindcss/typography')]
}

在外层标签上添加prose的className,prose-invert用于暗黑模式。


<article className="prose dark:prose-invert">{mdx}</article>

综上我们实现了对mdx的样式支持,然而有一点是@tailwindcss/typography并不会对mdx代码块中代码进行高亮。


代码高亮



写文章或多或少都有代码,高亮是必不可少,那么react-syntax-highlighter该上场了



定义一个CodeHighligher组件


// CodeHighligher.tsx

import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import {
oneDark,
oneLight
} from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { useTheme } from 'next-themes'

export default function CodeHighligher({
lang,
code
}: {
lang: string
code: string
}
) {
const { theme } = useTheme()
return (
<SyntaxHighlighter
language={lang?.replace(/\language-/, '') || 'javascript'}
style={theme === 'light' ? oneLight : oneDark}
customStyle={{
padding: 20,
fontSize: 15,
fontFamily: 'var(--font-family)'
}}
>

{code}
</SyntaxHighlighter>

)
}

react-syntax-highlighter高亮代码可用hljsprism,我在这使用的prism,两者都有众多代码高亮主题可供选择,lang如果没标注则默认设置为javascript也可以简写为js,值得注意的是如果是使用hljs,则必须写javascript,不可简写为js,否则代码高亮失败,这一点prism更加友好。


同时可通过useTheme实现亮色,暗色模式下使用不同代码高亮主题。


组件写好了,该如何使用?上面讲到过mdx的解析,在useMDXComponents重新渲染pre标签。


// mdx-components.tsx

import type { MDXComponents } from 'mdx/types'
import CodeHighligher from '@/components/CodeHighligher'

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
pre: ({ children }) => {
const { className, children: code } = props
return <CodeHighligher lang={className} code={code} />
}
}
}

mdx文件中代码块会被解析成pre标签,可以对pre标签返回值作进一步处理,即返回高亮组件,这样可实现对代码高亮,当然高亮主题很多,选自己喜欢的。


文章


元数据



文章一些信息如标题,描述,日期,作者等都作为文章的元数据,使用yaml语法定义



---
title: '文章标题'
description: '文章描述'
date: '2020-01-01'
---

@next/mdx默认不会按照yaml语法解析,这会被解析成h2标签,然而我们并不希望元数据被解析成h2标签作为内容展示,更希望拿这类数据做其他处理,
为了正确解析yaml,需要借助remark-frontmatter来实现。


使用该插件,注意需要修改next配置文件名为next.config.mjs,因为remark-frontmatter只支持ESM规范。


// next.config.mjs

import createMDX from '@next/mdx'
import frontmatter from 'remark-frontmatter'

const nextConfig = {}

const withMDX = createMDX({
options: {
remarkPlugins: [frontmatter]
}
})
export default withMDX(nextConfig)

yaml被正确解析了那么我们可以使用gray-matter来获取文章元数据


列表


由于app目录是运行在nodejs runtime下,基本思路是用nodejs的fs模块去读取文章目录即mdxs/posts,读取该目录下的所有文章放在一个list中。


使用fs.readdirSync读取文章目录内容,但是这仅仅是拿到文章名称的集合。


const POST_PATH = path.join(process.cwd(), 'mdxs/posts')

// 文章名称集合
export function getPostList() {
return fs.readdirSync(POST_PATH).map((name) => name.replace(/\.mdx/, ''))
}

文章列表中展示的是标题而不是名称,标题作为文章的元数据,通过gray-matterreadapi读取文件可获取(也可以使用fs.readFileSync) read返回datacontent的对象,
data是元数据信息,content则是文章内容。


export function getPostMetaList() {
const posts = getPostList()

return posts.map((post) => {
const {
data: { title, description, date }
} = matter.read(path.join(POST_PATH, `${post}.mdx`))

// 使用fs.readFileSync
// const post = fs.readFileSync(path.join(POST_PATH, `${post}.mdx`), 'utf-8')
// const {
// data: { title, description, date }
// } = matter(post)

return {
slug: post,
title,
description,
date
}
})
}

上述方法中我们拿到了所有文章标题,描述信息,日期的list,根据list渲染文章列表。


详情


文章列表中使用Link跳转到详情,通过dynamic动态加载文章对应的mdx文件


export default function LoadMDX(props: Omit<PostMetaType, 'description'>) {
const { slug, title, date } = props

const DynamicMDX = dynamic(() => import(`@/mdxs/posts/${slug}.mdx`), {
loading: () => <p>loading...</p>
})

return (
<>
<div className="mb-12">
<h1 className="mb-5 font-[600]">{title}</h1>
<time className="my-0">{date}</time>
</div>
<DynamicMDX />
</>

)
}

generateStaticParams



优化文章列表跳转详情的速度



在文章详情组件导出generateStaticParams方法,这个方法在构建时静态生成路由,而不是在请求时按需生成路由,一定程度上提高了访问详情页速度


export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())

return posts.map((post) => ({
slug: post.slug
}))
}

部署


项目是部署在vercel,使用github登录后我们新建一个项目,点进去后会看到Import Git Repository,导入对应仓库即可,也可使用vercel提供的模版新建一个,后续我们每次提交代码都会自动化部署。


Alt Text

有自己域名的可以在Domains中添加,然后去到你买域名的地方添加对应DNS解析即可。


总结


开发中遇到了一些坑:



  1. next-themes报错Warning: Extra attributes from the server: class,style,通过issues和看文档,最终找到了方案

  2. mdx-components组件的命名,经多次测试发现只能命名为mdx-components,阅读@next/mdx的源码也验证了

  3. 语法高亮,开始使用的hljs,mdx中的代码块写的js,部署到线上后发现代码并没有高亮,然后改用了prism正常高亮,
    又是阅读了react-syntax-highlighter源码发现hljs的语言集合中并没有js,所以无法正确解析,只能写成javascript,而prism两者写法都支持

  4. 首页的posts命令是运行在客户端组件中,fs无法使用,因此获取文章的方案使用fetch请求api

  5. 使用remark-frontmatter解析yaml无法和mdxRs: true同时使用,否则解析失败。添加此配置项表示使用基于rust的解析器来解析mdx,可能是还未支持的缘故


module.exports = withMDX({
experimental: {
mdxRs: true
}
})

后续更新:



  1. 会新增Weekly周刊模块,关注前端技术的更新

  2. 文章详情页添加上一篇和下一篇,更方便的阅读文章


作者:赫子子
来源:juejin.cn/post/7267408057163055139
收起阅读 »

一个鼠标滑过的样式~

web
🫰 demo 🫰🧐 思路分析 🧐这样看是不是一目了然呢~ 😏如上👆gif👆效果可以理解为👉 以鼠标位置为圆心,产生的背景圆,与box的间隙产生的交叉❓ 这么实现会不会有问题呢 ❓效果只在boxes区域出现,是不是需要判断鼠标位置来添加粉色背景圆呢 ❓...
继续阅读 »


hover.gif

🫰 demo 🫰

demo.gif

🧐 思路分析 🧐

原理.gif

这样看是不是一目了然呢~ 😏

如上👆gif👆效果可以理解为👉 以鼠标位置为圆心,产生的背景圆,与box的间隙产生的交叉

❓ 这么实现会不会有问题呢 ❓

  • 效果只在boxes区域出现,是不是需要判断鼠标位置来添加粉色背景圆呢 ❓
  • 而且这个只有在接触到 box 才会有 粉色背景圆box 以外的部分是没有颜色的,这个又如何解决呢 ❓
  • 或许也能实现,应该会麻烦些 :)

🧐 不妨换个思路 🧐

给每个box添加背景圆背景圆位置 根据鼠标位置变化,👇 如下所示 👇

image.png image.png

背景圆大小固定(比如200px),圆心位置如何确定呢?

👉 初始位置 (0,0) ,参照系则是参考box左上角

👉 动态变化的位置取(clientX - left, clientY - top)left  top  box 元素相对浏览器视口的位置,通过 getBoundingClientRect 方法获取

👉 取差值(clientX - left, clientY - top)也很好理解,因为伪元素位置是参照box左上的位置变化,这样就能在 差值(绝对值) < 半径 的时候出现在 box间隙

image.png

🌟 关键点 🌟

  • 盒子元素 box 添加伪元素 before,设置伪元素宽高均大于父元素,效果上类似于伪元素覆盖了box,同时设置偏移量 inset为负值,实现 “居中覆盖”(这样就能留出一个"空隙", 即👆gif👆粉色圆填充before  box 中间空白的部分)
  • 给伪元素背景设置背景色,demo中用的是 径向渐变,渐变的形状为200px 的圆形,圆心位置记为 --x  --y,通过css变量传入,颜色自定义即可(demo中采用的是rgba(245,158,11,.7)  transparent 的渐变)不用粉色了🤣

👀 关于--x  --y 的获取 👀

  • 记录鼠标位置 (mouseX, mouseY)
  • calBoxesPosition方法获取每个box 的位置 (left,top) 并记录差值 (mouseX - left, mouseY - top)
  • (mouseX, mouseY) 变动的时候重新触发 calBoxesPosition 方法即可

🚀 关于一些优化 🚀

  • 第一次页面加载调用 calBoxesPosition 后,在不滑动页面的情况下,每个box位置相对固定,可以缓存下来位置信息,避免该函数内部频繁调用 getBoundingClientRect 引发的性能问题造成卡顿
  • 滑动页面的时候,可以将记录box位置信息的字段重置为(0,0),再移动鼠标重新触发 calBoxesPosition 即可

👨‍💻代码(vue3实现)👨‍💻

PS: 不太会使用掘金的代码片段,不知道如何引入第三方库😅,如果验证代码, @vueuse/core 和 tailwindcss请自行安装🫠

(等我查一下怎么使用,再回来贴个代码片段~ ⏰@ 4-17 14:56 )

  1. template 结构

  1. css样式

  1. js 部分


作者:一只小於菟
来源:juejin.cn/post/7358622889681551372

收起阅读 »

threejs3D汽车换肤实战

web
06-汽车动态换肤的案列 课程内容 一、环境的搭建 (1)搭建项目 threejs的每个版本都有一些差异,在api和threejs项目文件夹下面,本案列使用的版本 npm i three@0.153.0 项目的目录结构如下: 03-fulldemo └───...
继续阅读 »

06-汽车动态换肤的案列


课程内容


一、环境的搭建


(1)搭建项目

threejs的每个版本都有一些差异,在api和threejs项目文件夹下面,本案列使用的版本


npm i three@0.153.0

项目的目录结构如下:


03-fulldemo
└───css
│───main.css

└───draco
│───gltf——存放Google Draco解码器插件

└───models——存放模型
│───ferrari.glb——模型文件,可以是glb也可以是gltf格式
│───ferrari_ao.png——模型贴图,这个图片是阴影效果

└───textures——纹理材质
│───venice_sunset_1k.hdr——将其用作场景的环境映射或者用来创建基于物理的材质


(2)代码基础结构搭建

创建对应的html文件并引入相应的环境


<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js webgl - materials - car</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link type="text/css" rel="stylesheet" href="./css/main.css">
<style>
body {
color: #bbbbbb;
background: #333333;
}
a {
color: #08f;
}
.colorPicker {
display: inline-block;
margin: 0 10px
}
</style>
</head>

<body>
<!--设置三个按钮,用于切换车身、轮毂、玻璃的颜色-->
<div id="info">
<span class="colorPicker"><input id="body-color" type="color" value="#ff0000"></input><br/>Body</span>
<span class="colorPicker"><input id="details-color" type="color" value="#ffffff"></input><br/>Details</span>
<span class="colorPicker"><input id="glass-color" type="color" value="#ffffff"></input><br/>Glass</span>
</div>
<!--要渲染3D的容器-->
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "./node_modules/three/build/three.module.js",
"three/addons/": "./node_modules/three/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
//用于显示屏幕渲染帧率的面板
import Stats from 'three/addons/libs/stats.module.js';
//相机控件OrbitControls实现旋转缩放预览效果。
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
//加载GLTF文件格式的加载器,用于加载外部为gltf的文件
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
//Draco是一个用于压缩和解压缩 3D 网格和点云的开源库
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
//RGBELoader可以将HDR图像加载到Three.js应用程序中
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';

//下面的代码就是JS渲染逻辑代码
</script>
</body>
</html>

在css/main.css文件中我们的代码如下


body {
margin: 0;
background-color: #000;
color: #fff;
font-family: Monospace;
font-size: 13px;
line-height: 24px;
overscroll-behavior: none;
}

a {
color: #ff0;
text-decoration: none;
}

a:hover {
text-decoration: underline;
}

button {
cursor: pointer;
text-transform: uppercase;
}

#info {
position: absolute;
top: 0px;
width: 100%;
padding: 10px;
box-sizing: border-box;
text-align: center;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
z-index: 1; /* TODO Solve this in HTML */
}

a, button, input, select {
pointer-events: auto;
}

.lil-gui {
z-index: 2 !important; /* TODO Solve this in HTML */
}

@media all and ( max-width: 640px ) {
.lil-gui.root {
right: auto;
top: auto;
max-height: 50%;
max-width: 80%;
bottom: 0;
left: 0;
}
}

#overlay {
position: absolute;
font-size: 16px;
z-index: 2;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(0,0,0,0.7);
}

#overlay button {
background: transparent;
border: 0;
border: 1px solid rgb(255, 255, 255);
border-radius: 4px;
color: #ffffff;
padding: 12px 18px;
text-transform: uppercase;
cursor: pointer;
}

#notSupported {
width: 50%;
margin: auto;
background-color: #f00;
margin-top: 20px;
padding: 10px;
}


效果如下图:


image-20230627175428242


二、进行3D场景的渲染


(1)进行初始化函数设计

在项目中我们添加一个carInit函数进行动画的初始化


...省略之前代码
//下面的代码就是JS渲染逻辑代码
let scene, renderer, grid, camera;
function initCar(){
//里面就开始进行3D场景的搭建
}

//执行初始化函数
initCar()

上面的函数设计用于执行我们所有3d业务代码。


(2)创建场景

/**
* (1)获取要渲染的容器
*/

const container = document.getElementById('container');

/**
* (2)创建场景对象Scene
*/

//创建一个场景对象,用来模拟3d世界
scene = new THREE.Scene();
//设置一个场景的背景颜色
scene.background = new THREE.Color(0x333333);
//这个类中的参数定义了线性雾。也就是说,雾的密度是随着距离线性增大的
scene.fog = new THREE.Fog("red", 10, 15);

background:这个属性用于设置我们场景的背景颜色,0x333333默认采用深灰来作为我们初始颜色


fog:定义了线性雾,类似于在背景指定位置设置雾化的效果,让背景看起来更加模糊,凸显空旷效果。


(3)坐标格辅助对象

/**
* (3)坐标格辅助对象. 坐标格实际上是2维线数组.
*/

//创建网格对象,参数1:大小,参数2:网格细分次数,参数3:网格中线颜色,参数4:网格线条颜色
grid = new THREE.GridHelper(40, 40, 0xffffff, 0xffffff);
//网格透明度
grid.material.opacity = 1;
grid.material.depthWrite = false;
grid.material.transparent = true;
scene.add(grid);

坐标格辅助对象GridHelper可以在3D场景中定义坐标格出现。后续我们会在坐标格上面放我们的模型进行展示


代码编写完毕后,最终渲染出来的坐标格效果如下:


image-20230628155733478


(4) 创建相机对象

/**
* (4)创建透视相机
* 参数一:摄像机视锥体垂直视野角度
* 参数二:摄像机视锥体长宽比
* 参数三:摄像机视锥体近端面
* 参数四:摄像机视锥体远端面
*/

camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.3, 100);
camera.position.set(0, 1.4, - 4.5);

任何一个3D渲染效果都需要相机来成像


这一投影模式被用来模拟人眼所看到的景象,它是3D场景的渲染中使用得最普遍的投影模式


透视相机最大的特点就是满足近大远小的效果。


(5)创建一个渲染器

/**
* (5)创建一个渲染器
*/

renderer = new THREE.WebGLRenderer({ antialias: true });
renderer = new THREE.WebGLRenderer({ antialias: true });
//设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
//设置渲染出来的画布范围
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
renderer.render(scene, camera);

有了场景、相机、坐标格辅助,我们想要让画面能够呈现出来,那就得有渲染器。


相当于你拍照需要将画面呈现到交卷上面。


其中renderer.render(scene, camera); 这段代码就是在进行渲染器的渲染。


如果render在指定频率内不断被调用,那就意味着可以不断拍照,不断渲染。可以实现动态切换效果


(6)效果渲染

当执行完上面的代码后,你需要确保调用了carInit这个函数,页面就可以渲染出对应的效果了


image-20230628161312404


说明:



  1. 场景的背景色为0x333333效果为深灰色。

  2. 我们设置的fog线性雾颜色为红色,所以你会发现在背景和网格之间会有一个过渡颜色。

  3. 网格的颜色采用的是0xffffff效果为灰色。


对应的各种参数,当你在学习的时候都都可以进行调整。一遍调整就能看懂参数和最终渲染的效果差异。


当你把fog的颜色调整为跟背景一样的时候,你会发现画面上就类似产生了迷雾效果,让3D背景更加立体


scene.fog = new THREE.Fog(0x333333, 10, 15);

效果如下:


image-20230628161707934


你也可以继续设置网格线条的透明度,让网格线不那么抢眼


grid.material.opacity = 0.3;

效果如下:


image-20230628161827094


是不是整个画面看起来3D立体效果会更强一些,背景看起来更深邃一些。


三、加载外部模型进行渲染


(1)添加轨道控制器

threejs官方给我们提供了一个类,OrbitControls(轨道控制器)可以使得相机围绕目标进行轨道运动。


换句话说,引入了OrbitControls后,我们可以操作鼠标来控制页面上动态效果。


比如:鼠标滚动、鼠标点击、鼠标左右滑动效果。


代码如下:


...省略了 【(5)创建一个渲染器】
/**
* (6)开启OrbitControls控件,可以支持鼠标操作图像
*/

controls = new OrbitControls(camera, container);
//你能够将相机向外移动多少(仅适用于PerspectiveCamera),其默认值为Infinity
controls.maxDistance = 9;
//你能够垂直旋转的角度的上限,范围是0到Math.PI,其默认值为Math.PI
controls.maxPolarAngle = THREE.MathUtils.degToRad(90);
controls.target.set(0, 0.5, 0);
controls.update();

加入上面代码后,我们还要继续优化代码


在carInit函数后面在添加一个render函数,用于执行渲染


function initCar(){

/**
* (5)创建一个渲染器
*/

renderer = new THREE.WebGLRenderer({ antialias: true });
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
//注释掉这句话
//renderer.render(scene, camera);
//调用一次render函数进行渲染
render()
}
function render(){
renderer.render(scene, camera);
requestAnimationFrame(render)
}

效果实现如下:



(2)加载汽车模型

既然要加载外部模型,那我们肯定需要通过模型软件来设计对应的模型。本案列不讲解如何设计模型,我使用threejs官方提供的模型来进行展示。


我们常用的模型格式如下:



  1. OBJ (Wavefront OBJ):


    OBJ 是一种常见的纯文本模型格式,支持存储模型的几何信息(顶点、面)和材质信息(纹理坐标、法线等)。可以通过OBJLoader来加载和解析OBJ格式的模型。


  2. FBX (Autodesk FBX):


    FBX 是由Autodesk开发的一种常用的二进制模型格式,支持存储模型的几何信息、材质、动画等。可以通过FBXLoader来加载和解析FBX格式的模型。


  3. GLTF (GL Transmission Format):


    GLTF 是一种基于JSON的开放标准,用于存储和传输三维模型和场景。GLTF格式支持几何信息、材质、骨骼动画、节点层次结构等,并且通常具有较小的文件大小。可以通过GLTFLoader来加载和解析GLTF格式的模型。


  4. STL (Stereolithography):


    STL 是一种常用的三维打印文件格式,用于存储模型的几何信息。STL 文件通常包含三角形面片的列表,用于定义模型的外观。可以通过STLLoader来加载和解析STL格式的模型。


  5. GLB:


    GLB是GL Transmission Format(gltf)的二进制版本,GLB格式将模型的几何信息、材质、骨骼动画、节点层次结构等存储在单个二进制文件中,通常具有较小的文件大小和更高的加载性能.



本案列采用glb格式来加载外部模型。


因为案列中使用glb模型数据采用了Draco来进行压缩,所以我们需要引入DRACOLoader来解析我们的模型


(1)引入DRACOLoader加载模型


/**
* (7)汽车模型相关的内容
* DRACOLoader 主要用于解析使用 Draco 压缩的 GLB 模型,而不是所有的 GLB 模型都使用了 Draco 压缩
*/

const dracoLoader = new DRACOLoader();
//配置加载器的位置,这个需要提前下载到项目中
dracoLoader.setDecoderPath('./draco/gltf/');
const loader = new GLTFLoader();
//设置GLTFLoader加载器使用DRACO来解析我们的模型数据
loader.setDRACOLoader(dracoLoader);


并不是所有的模型都需要Draco来进行加载,取决于你的模型在设计导出的时候是否用了Draco来进行压缩。



./draco/gltf/目录下面的文件如下:代码可以从gitee上面下载


image-20230629142933880


(3)加载glb模型数据

当你已经创建了`const loader = new GLTFLoader();这个类实例后,我们就可以加载模型了


/**
* (8)加载glb模型
*/

loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];
//将模型添加到3D场景中
scene.add(carModel);
});
render()

加载的效果如下:


image-20230629143514305


模型已经加载成功了,但是你会发现他在整个背景中是黑色的。当然模型本身是有材质贴图的,车身默认是红色的。


之所以产生这个效果那是因为我们现在缺少一个非常重要的元素,那就是光照。


你试想一下,一个物体在没有任何光源的情况下,呈现出来的就是黑色的效果。如果你的场景背景也是黑色,那根本看不到效果。


(4)加载光影效果

我们设置光源的时候主要有两个部分



  1. 环境光:相当于天空的颜色,物体表面可以反射出对应的颜色。

  2. 点光源:相当于开启手电筒,照射到模型表面反射出来的颜色。


设置环境光


/**
* (9)添加光影效果
*/


//创建环境光
var ambient = new THREE.AmbientLight("blue");
scene.add(ambient);

环境光的颜色为blue,效果如下:


image-20230629145635074


环境光为blue的情况下,模型表面反射出来的颜色就是蓝色,一般金属材质和玻璃材质反射的效果更佳明显。所以轮毂和车辆挡风玻璃效果会更强烈一些。


设置点光源


/**
* (9)添加光影效果
*/


//创建环境光
var ambient = new THREE.AmbientLight("blue");
scene.add(ambient);

//创建点光源
var point = new THREE.PointLight("#fff");
//设置点光源位置
point.position.set(0, 300, 0);
//点光源添加到场景中
scene.add(point);

效果如下:


image-20230629145913328


此刻我们基本上完成了模型的渲染,环境光蓝色默认替换为黑色,这样车辆立体感会更强一些


//环境光
var ambient = new THREE.AmbientLight("#000");

效果如下:


image-20230629150241875


(5)加载hdr文件设置环境渲染

HDR(High Dynamic Range)文件是一种存储图像高动态范围信息的文件格式。


HDR可以理解成一张真实世界的图片或者设计者想要的灯光效果。


他的作用主要如下:



  1. HDR文件经常被用作环境贴图,用于模拟反射和光照环境。环境贴图是将场景的背景、反射和光照信息包装成一个纹理,然后将其应用到物体表面上。通过使用HDR文件作为环境贴图,可以更真实地模拟光线在场景中的反射和折射,增强渲染效果。

  2. HDR文件还可以用于模拟全局照明效果。全局照明是一种渲染技术,它考虑了场景中所有光源的组合对物体的影响,以获得更真实的照明效果。通过使用HDR文件提供的高动态范围和丰富的光照信息,可以在Three.js中实现更逼真的全局照明效果


也就说在本案列中如果我们想要获取更加真实的照明效果,我们可以使用设计师导出的hdr文件。将这个文件作为3D场景(Scene)的环境贴图


/**
* (2)创建场景对象Scene
*/

scene = new THREE.Scene();
scene.background = new THREE.Color(0x333333);
//通过RGBELoader加载hdr文件,它是一种图像格式,将其用作场景的环境映射或者用来创建基于物理的材质
scene.environment = new
RGBELoader().load('textures/equirectangular/venice_sunset_1k.hdr');
scene.environment.mapping = THREE.EquirectangularReflectionMapping;
scene.fog = new THREE.Fog(0x333333, 10, 15);

删除我们(9)添加光影效果中我们自己的光影效果


/**
* (9)添加光影效果
*/


//创建环境光
//var ambient = new THREE.AmbientLight("blue");
//scene.add(ambient);

//创建点光源
//var point = new THREE.PointLight("#fff");
//设置点光源位置
//point.position.set(0, 300, 0);
//点光源添加到场景中
//scene.add(point);

这样渲染下来我们物体在场景中显示的会更加自然


image-20230629152457227



不管你用hdr文件来作为环境贴图,还是采用光源设置来设计,我们都可以让模型在3D场景中更方便的显示出来。



四、汽车材质贴图


目前我们已经将模型渲染出来了,但是你会发现不管是车身、轮毂、还是玻璃材质跟我们想要的真实车辆材质是有区别的。比如你希望玻璃透明的、反光的。车身的漆面是可以反光的。模型在设计的时候使用默认材质。我们想要进行材质的替换。


(1)在步骤8中继续优化代码

/**
* (8)加载glb模型
* 并设置不同部位的材质。
*/

//物理网格材质(MeshPhysicalMaterial)
//车漆,碳纤,被水打湿的表面的材质需要在面上再增加一个透明的
const bodyMaterial = new THREE.MeshPhysicalMaterial({
color: 0xff0000, metalness: 1.0, roughness: 0.5, clearcoat: 1.0, clearcoatRoughness: 0.03
});

//汽车轮毂的材质,采用了标准网格材质,threejs解析gltf模型,会用两种材质PBR材质去解析
const detailsMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff, metalness: 1.0, roughness: 0.5
});

//汽车玻璃的材质
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0xffffff, metalness: 0.25, roughness: 0, transmission: 1.0
});

loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];
//将模型添加到3D场景中
scene.add(carModel);
});

材质创建了过后,接下来我们就可以将材质加载了到模型中了。


loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];

//获取模型中指定的模块,将默认材质替换为我们自定义材质
carModel.getObjectByName('body').material = bodyMaterial;
//轮毂的材质替换
carModel.getObjectByName('rim_fl').material = detailsMaterial;
carModel.getObjectByName('rim_fr').material = detailsMaterial;
carModel.getObjectByName('rim_rr').material = detailsMaterial;
carModel.getObjectByName('rim_rl').material = detailsMaterial;
////座椅的材质
carModel.getObjectByName('trim').material = detailsMaterial;
//玻璃的材质替换
carModel.getObjectByName('glass').material = glassMaterial;

scene.add(carModel);
});

上面的代码分别是获取模型中车身区域(body),获取轮毂区域(rim_fl、rim_fr、rim_rr、rim_rl)、座椅区域(trim)、玻璃区域(glass)


将我们自己创建的材质拿去替换默认材质实现加载渲染。


效果如下:


image-20230629164136907


替换过后的模型,更有金属质感和玻璃质感。材质对应的颜色你们都可以自己进行替换。


(2)给车底盘添加阴影效果


车底盘是没有阴影效果的,我们可以使用图片来进行模型贴图,让底盘有阴影效果会更加立体。


贴图的图片为png,图片由设计师出的


效果如下:


ferrari_ao


创建一个材质对象,并使用这张图片作为贴图


loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];

//获取模型中指定的模块,将默认材质替换为我们自定义材质
carModel.getObjectByName('body').material = bodyMaterial;
//轮毂的材质替换
carModel.getObjectByName('rim_fl').material = detailsMaterial;
carModel.getObjectByName('rim_fr').material = detailsMaterial;
carModel.getObjectByName('rim_rr').material = detailsMaterial;
carModel.getObjectByName('rim_rl').material = detailsMaterial;
//座椅的材质
carModel.getObjectByName('trim').material = detailsMaterial;
//玻璃的材质替换
carModel.getObjectByName('glass').material = glassMaterial;

// shadow阴影效果图片
const shadow = new THREE.TextureLoader().load( './models/gltf/ferrari_ao.png' );
// 创建一个材质模型
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(0.655 * 4, 1.3 * 4),
new THREE.MeshBasicMaterial({
map: shadow, blending: THREE.MultiplyBlending, toneMapped: false, transparent: true
})
);
mesh.rotation.x = - Math.PI / 2;
mesh.renderOrder = 2;
carModel.add(mesh);

scene.add(carModel);
});

效果如下:


image-20230629175934463


通过效果图能看出,车辆底部是有阴影效果的,让整个3D效果渲染更加立体。


五、设置动画效果


(1)获取轮毂的材质对象

轮毂和网格地板我们都要动画加载


网格需要进行平移,按照z的反方向进行移动。


轮毂需要按照x轴的方向进行旋转


代码如下:


let wheels = []
function initCar(){
loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];

...省略代码
//将车轮的模块保存到数组中,后面可以设置动画效果
wheels.push(
carModel.getObjectByName('wheel_fl'),
carModel.getObjectByName('wheel_fr'),
carModel.getObjectByName('wheel_rl'),
carModel.getObjectByName('wheel_rr')
);

scene.add(carModel);
});
}

上面的代码将轮毂模块获取到过后,放入到wheels数组中。


(2)设置轮毂的动画效果

接下来在render函数中进行动画控制


function render() {
controls.update();
//performance.now()是一个用于测量代码执行时间的方法。它返回一个高精度的时间戳,表示自页面加载以来的毫秒数
const time = - performance.now() / 1000;
//控制车轮的动画效果
for (let i = 0; i < wheels.length; i++) {
wheels[i].rotation.x = time * Math.PI * 2;
}
//控制网格的z轴移动
grid.position.z = - (time) % 1;

renderer.render(scene, camera);
requestAnimationFrame(render)
}

通过上面的代码我们已经能够实现轮毂和网格的动画效果了


六、切换颜色


实现颜色切换就必须绑定js的事件。


三个按钮,我们都绑定点击事件,并获取对应的颜色


function initCar(){
...省略代码
/**
* (10)切换车身颜色
* 获取到指定的按钮,得到你选中的颜色,并将颜色设置给我们自己的模型对象
*/

const bodyColorInput = document.getElementById('body-color');
bodyColorInput.addEventListener('input', function () {
bodyMaterial.color.set(this.value);
});

const detailsColorInput = document.getElementById('details-color');
detailsColorInput.addEventListener('input', function () {
detailsMaterial.color.set(this.value);
});

const glassColorInput = document.getElementById('glass-color');
glassColorInput.addEventListener('input', function () {
glassMaterial.color.set(this.value);
});
}

当我们将上面的代码实现后,切换颜色就完成分了。


只要修改bodyMaterial材质对象的颜色,页面刷新的时候就可以应用成功。


课程小结


作者:无处安放的波澜
来源:juejin.cn/post/7277787934848204835
收起阅读 »

Android 时钟翻页效果

背景 今天逛掘金,发现了一个有意思的web view效果,想着Android能不能实现一下捏。 原文链接:juejin.cn/post/724435… 具体实现分析请看上文原文链接,那我们开始吧! 容器 val space = 10f //上下半间隔 val...
继续阅读 »

背景


今天逛掘金,发现了一个有意思的web view效果,想着Android能不能实现一下捏。


image.png
原文链接:juejin.cn/post/724435…


具体实现分析请看上文原文链接,那我们开始吧!


容器


val space = 10f //上下半间隔
val bgBorderR = 10f //背景圆角
//上半部分
val upperHalfBottom = height.toFloat() / 2 - space / 2
canvas.drawRoundRect(
0f,
0f,
width.toFloat(),
upperHalfBottom,
bgBorderR,
bgBorderR,
bgPaint
)
//下半部分
val lowerHalfTop = height.toFloat() / 2 + space / 2
canvas.drawRoundRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat(),
bgBorderR,
bgBorderR,
bgPaint
)

image.png


绘制数字


我们首先居中绘制数字4


val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
//居中显示
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom
canvas.drawText(number4, x, y, textPaint)

image.png


接下来我们将数字切分为上下两部分,分别绘制。


val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom
// 上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()

image.png


翻转卡片


如何实现让其旋转呢?
而且还得是3d的效果了。我们选择Camera来实现。
我们先让数字'4'旋转起来。


准备工作,通过属性动画来改变旋转的角度。


private var degree = 0f //翻转角度
private val camera = Camera()
private var flipping = false //是否处于翻转状态
...
//动画
val animator = ValueAnimator.ofFloat(0f, 360f)
animator.addUpdateListener { animation ->
val animatedValue = animation.animatedValue as Float
setDegree(animatedValue)
}
animator.doOnStart {
flipping = true
}
animator.doOnEnd {
flipping = false
}
animator.duration = 1000
animator.interpolator = LinearInterpolator()
animator.start()
...

private fun setDegree(degree: Float) {
this.degree = degree
invalidate()
}

让数字'4'旋转起来:


  override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
canvas.drawText(number4, x, y, textPaint)
} else {
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
}
}

file.gif

我们再来看一边效果图:
我们希望将卡片旋转180度,并且0度-90度由上半部分完成,90度-180度由下半部分完成。


我们调整一下代码,先处理一下上半部分:


...
val animator = ValueAnimator.ofFloat(0f, 180f)
...
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val space = 10f //上下半间隔
//上半部分
val upperHalfBottom = height.toFloat() / 2 - space / 2
...
// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
} else {
if (degree < 90) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
}
}
}

效果如下:


upper.gif

接下来我们再来看一下下半部分:


override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val space = 10f //上下半间隔
//下半部分
val lowerHalfTop = height.toFloat() / 2 + space / 2

// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
} else {
if (degree > 90) {
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
camera.save()
canvas.translate(width / 2f, height / 2f)
val bottomDegree = 180 - degree
camera.rotateX(bottomDegree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
}
}
}

lower.gif

那我们将上下部分结合起来,效果如下:


all.gif

数字变化


好!我们完成了翻转部分,现在需要在翻转的过程中将数字改变:

我们还是举例说明:数字由'4'变为'5'的情况。我们思考个问题,什么时候需要改变数字?

上半部分在翻转开始的时候,上半部分底部显示的数字就应该由'4'变为'5',但是旋转的部分还是应该为'4',
下半部分开始旋转的时候底部显示的数字还是应该为'4',而旋转的部分该为'5'。


canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number5, x, y, textPaint)
canvas.restore()
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码显示的上下底部显示的内容,即上半部分地步显示5,下半部分显示4
if (degree < 90) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码表示上半部分旋转显示的内容,即数字4
} else {
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
camera.save()
canvas.translate(width / 2f, height / 2f)
val bottomDegree = 180 - degree
camera.rotateX(bottomDegree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number5, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码表示下半部分旋转显示的内容,即数字5
}

效果图如下:大伙可以在去理一下上面数字的变化的逻辑。


a.gif

最后我们加上背景再看一下效果:


a.gif

小结


上述代码仅仅提供个思路,仅为测试code,正式代码可不能这么写哦 >..<


作者:蹦蹦蹦
来源:juejin.cn/post/7271518821809438781
收起阅读 »

Android 15 可能最终修复了底部黑色导航栏问题

Android 15 可能最终修复了底部黑色导航栏问题 长期以来, Android 系统一直存在一个问题: 手势栏/药丸/三键导航下面有一个可笑的黑条. 我曾找过相关的截图, 但要么截图太旧, 要么截图不清晰. 因为我一直在使用各种方法来隐藏它. 这就是手势...
继续阅读 »

Preview image


Android 15 可能最终修复了底部黑色导航栏问题


长期以来, Android 系统一直存在一个问题: 手势栏/药丸/三键导航下面有一个可笑的黑条. 我曾找过相关的截图, 但要么截图太旧, 要么截图不清晰. 因为我一直在使用各种方法来隐藏它.


这就是手势栏, 它并不那么碍事. Android 系统也有三键导航功能, 如果你打开它, 黑条就会变得非常大.


这是因为在早期的 Android 系统中, 屏幕上的导航按钮是用来取代实体按键的. 但老实说, 由于屏幕与静态按键不同, 效果并不理想. 这感觉更像是一个噱头. 当他们最终推出允许用户在'沉浸模式'下隐藏按键的 API 时, 我真的很不喜欢, 因为这意味着你必须做这个愚蠢的轻扫手势才能按到按键.


总之, 随着屏幕边框越来越小, 我们需要更大的显示屏, 屏幕底部的黑条开始变得有些碍眼和不必要. 最初, 在定制的 Roms 中, 有一种叫做"派控制 器"(Pie Controll)的东西. 那是一段美好时光. 你只需在屏幕边缘轻扫, 就会跳出一堆按钮. 虽然我用得不多, 因为我的手机大多是电容按键.


Android pie controls


我确实改用了手势导航, 也正是从那时起, 我才真正开始讨厌黑条. 因为它看起来太多余了. iOS 系统没有黑条, 而 iPhone 却运行得非常好. 所以这些年来我一直在尝试禁用这个黑条. 最初, 我使用的是 Xposed 模块. 后来, 我用了带电容按键的手机, 这样我就再也不用看到这个栏了. 这样做的好处是, Android 系统不会在你轻扫之前隐藏按钮.


后来, 我发现了一款名为"流体导航手势"(Fluid Navigation Gestures)的应用, 它曾在一段时间内起过作用. 但我目前的解决方案是只使用 iOS, 因为 Android 手机现在太难root了.


但据 Android Authority 报道, 这个问题可能最终会消失.



有鉴于此, 当我翻阅 Android 14 QPR2 Beta 3 时, 我发现了一个名为 EDGE_TO_EDGE_BY_DEFAULT的新应用兼容性更改, 其描述如下: "如果目标 SDK 为 VANILLA_ICE_CREAM或更高版本, 则应用默认为Edge-to-Edge. Vanilla Ice Cream恰好是 Android 15 的内部甜点名称, 这意味着这一兼容性变更将适用于以今年即将发布的版本为目标的应用. 鉴于Google每年都会强制开发者更新他们的应用, 以适应更新的 API 级别, 因此 Play Store 上的大多数应用都会以 Android 15 为目标, 这只是时间问题. 除非Google再次修改政策, 否则新应用和应用更新将被迫以 Android 15 为目标的截止日期将是 2025 年 8 月 31 日.



我不确定Google是否强迫你每年更新应用. 我不认为他们会这样做, 因为 Google Play 上有一些非常老旧的应用, 我敢肯定它们已经不再被维护了. 现在, 如果你不按照新规定更新应用, 他们可以把你的应用踢出去, 但并没有那么多规定. 我想作者的意思是, 当你更新应用时, 你必须使用最新的 SDK, 也就是香草冰淇淋的 SDK, 很可能是 SDK 35 级.


总之, Edge-to-Edge模式是一种允许应用在整个屏幕上绘图的方式. 目前, 默认情况下 Android 应用无法在状态栏和导航栏上绘图. 除此之外, 状态栏和导航栏还会变成半透明状态, 这意味着你可以看到它们下方的内容.


现在, 无论出于何种原因, 有些人并不喜欢这一变化. 可能是因为这意味着内容被手势区域或按钮遮住了. 是的, 这种情况偶尔会发生. 我最近就遇到了这种情况.


正如你在上面的片段中看到的, 在过渡到放大视图时, 页面指示器的位置太低了. 在 iOS 上使用手势导航时问题不大, 但在使用三键导航时就会出现问题. 虽然在这个特定的例子中, 页面指示器不是可点击的, 所以问题不大.


但 iOS 这样做已经有很长一段时间了, 好像自己 iPhone X 就这样了. 他们有一个非常简单的方法来实现这一功能, 叫做安全区域 API. 我不太清楚它在本地是如何实现的, 但在 Flutter 中却很容易实现. 你只需使用 SafeArea() 对象或调用 MediaQuery.of(context).padding. 尽管 MediaQuery 有 3 个 padding 值. 这有点令人困惑.


总之, 我的所有应用都使用了专门的代码, 以确保 Android 应用从 Edge-to-Edge 显示. 这是因为我希望 iOS 和 Android 版本看起来一样. 此外, 我也更喜欢'Edge-to-Edge'的外观.


await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
statusBarColor: Colors.transparent,
),
)
;

这也使得状态栏和导航栏完全透明, 而不仅仅是半透明. 不过有一个小问题: 你必须确保状态栏图标的颜色正确, 否则它们将无法显示. 实际上, 这有点困难, 因为你必须根据你所处的屏幕来改变它们的颜色, 但这并不难.


要在 Flutter 中实现这一点, 你必须将Scaffold包裹在AnnotatedRegion<SystemUiOverlayStyle>中, 其值就是此函数的结果:


SystemUiOverlayStyle makeUiOverlayStyle(bool whiteIcons) {
return SystemUiOverlayStyle(
statusBarBrightness: whiteIcons ? Brightness.dark : Brightness.light,
statusBarIconBrightness: whiteIcons ? Brightness.light : Brightness.dark,
);
}

就是这样. 改变并不难, 我很高兴 Google 终于做到了. 俗话说, 迟到总比不到好.


虽然有些人认为这种改变还不够. 他们希望取消手势"提示". 这样, 你在使用手势时就不会看到屏幕底部的白条了. 要知道, 早在root很容易的时候, 我就在我的 Android 手机上这样做了. 以及最近的流畅导航手势(Fluid Navigation Gestures). 它是一种更简约的UI, 但我不认为它能给用户体验带来多少好处. 我认为强制应用采用 'Edge-to-Edge' 的设计已经足够好了.


作者:bytebeats
来源:juejin.cn/post/7356793698052866048
收起阅读 »

JavaScript简介:从概念、特点、组成和用法全面带你快速了解JavaScript!

JavaScript,简称JS,是一种轻量级的解释型编程语言,它是网页开发中不可或缺的三剑客之一,与HTML和CSS并肩作战,共同构建起我们浏览的网页。今天我们就来了解一下JavaScript,看看它在我们的web前端开发中扮演着什么样的角色。一、JavaSc...
继续阅读 »

JavaScript,简称JS,是一种轻量级的解释型编程语言,它是网页开发中不可或缺的三剑客之一,与HTML和CSS并肩作战,共同构建起我们浏览的网页。

今天我们就来了解一下JavaScript,看看它在我们的web前端开发中扮演着什么样的角色。

一、JavaScript是什么?

JavaScript(简称“JS”)是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。它以其作为开发Web页面的脚本语言而闻名,但也被广泛应用于非浏览器环境中。

JavaScript是一种基于原型编程、多范式的动态脚本语言,支持面向对象、命令式、声明式和函数式编程范式。


Description


JavaScript最初由Netscape公司的Brendan Eich于1995年为网景导航者浏览器设计并实现。由于Netscape与Sun的合作,Netscape管理层希望该语言在外观上看起来像Java,因此得名为JavaScript。

JavaScript的标准是ECMAScript。截至2012年,所有浏览器都完整地支持ECMAScript 5.1,旧版本的浏览器至少支持ECMAScript 3标准。

2015年6月17日,ECMA国际组织发布了ECMAScript的第六版,正式名称为ECMAScript 2015,但通常被称为ECMAScript 6或ES2015。


Description


JavaScript目前是互联网上最流行的脚本语言。这门语言不仅可用于HTML和Web开发,还可以广泛用于服务器、PC、笔记本电脑、平板电脑和智能手机等设备。

二、JavaScript能做什么?

动画效果:

让你的网页动起来,比如轮播图、下拉菜单等。


Description

表单验证:

在数据提交到服务器之前,进行即时的客户端验证。

异步请求:

通过AJAX技术,实现页面的局部更新,无需刷新整个页面。

交互式游戏:

创建复杂的网页游戏,或是简单的互动元素。

Web API:

利用浏览器提供的API,访问地理位置、摄像头、本地存储等。


Description

跨平台应用:

使用如React Native、Electron等框架,开发跨平台的移动应用和桌面应用。

后端开发:

Node.js的出现让JavaScript也能在服务器端大展拳脚。

三、JavaScript的组成

  • ECMAScript,描述了该语言的语法和基本对象。

  • 文档对象模型(DOM),描述处理网页内容的方法和接口。

  • 浏览器对象模型(BOM),描述与浏览器进行交互的方法和接口

Description


3.1 ECMAScript:

ECMAScript是一种由Ecma国际(前身为欧洲计算机制造商协会)在标准ECMA-262中定义的脚本语言规范。这种语言在万维网上应用广泛,它往往被称为JavaScript或JScript,但实际上后两者是ECMA-262标准的实现和扩展。

简单来说:

  • ECMAScript JavaScript是的核心,是规范标准。

  • 描述了语言的基本语法(var、for、if、array等)和数据类型(数字、字符串、布尔、函数、对象(obj、[]、{}、null)、未定义)。

3.2 DOM

文档对象模型 (DOM) 是HTML和XML文档的编程接口。它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。

DOM 将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合。

简言之,它会将web页面和脚本或程序语言连接起来。

可以理解为:

DOM Document Object Model文档对象模型,可以去操作网页。

Document(文档)

指的是XML和HTML的页面,当你创建一个页面并且加载到Web浏览器中,DOM就在幕后悄然而生,它会把你编写的网页文档转换成一个文档对象。

Object(对象)

js对象大致可以分为以下三种:

  • 用户定义对象,例如:var obj = {}

  • 内置对象,无需创建,可直接使用,例如:Array、Math和Data等

  • 宿主对象,浏览器提供的对象,例如:window、document

DOM中主要关注的就是document,document对象的主要功能就是处理网页内容。

Model(模型)

代表着加载到浏览器窗口的当前网页,可以利用JavaScript对它进行读取。

3.3 BOM

浏览器对象模型,操作浏览器。

Browser Object Model 浏览器对象模型提供了独立与内容的、可以与浏览器窗口进行互动的对象结构,BOM由多个对象构成,其中代表浏览器窗口的window对象是BOM的顶层对象,其他对象都是该对象的子对象。

四、JavaScript的特点

JavaScript是一种功能强大的编程语言,它的特点主要包括以下几点,在这里大家只需要了解一下就可以了。

  • 客户端脚本语言: JavaScript通常在用户的浏览器上运行,用于实现动态内容和用户界面的交互性。

  • 弱类型语言: JavaScript不要求开发者在编程时明确指定变量的类型,类型会在运行时自动转换。

  • 面向对象: JavaScript支持面向对象的编程模式,允许创建对象和定义它们之间的交互。

  • 事件驱动: JavaScript能够响应用户的操作(如点击、输入等),这使得它非常适合构建交互式的Web应用。

  • 跨平台: JavaScript代码可以在几乎所有的现代浏览器上运行,无论是Windows、macOS还是Linux操作系统。

  • 动态性: JavaScript是一种动态语言,可以在运行时改变其结构和行为。

  • 可扩展性: JavaScript可以通过添加新的函数和属性来扩展其内置对象的功能。

  • 宽松语法: JavaScript的语法相对宽松,使得编程更加灵活,但也可能导致错误。

  • 单线程与异步处理: JavaScript在浏览器中是单线程执行的,但它通过事件循环和回调函数等机制实现了异步处理。

  • 基于原型的继承: 不同于传统的类继承,JavaScript使用的是基于原型的继承方式。

  • 核心组成部分: JavaScript的核心由ECMAScript、DOM(文档对象模型)和BOM(浏览器对象模型)组成。

  • 多范式: JavaScript支持多种编程范式,包括过程式、面向对象和函数式编程。

总的来说,JavaScript的这些特点使其成为了Web开发中不可或缺的一部分,同时也适用于服务端编程(如Node.js)和其他非浏览器环境。


你是不是厌倦了一成不变的编程模式?想要突破自我,挑战新技术想要突破自我,挑战新技术?却迟迟找不到可以练手的项目实战?是不是梦想打造一个属于自己的支付系统?那么,恭喜你,云端源想免费实战直播——《VUE3+SpringBoot搭建移动支付功能(第1期)》即将开启,点击前往获取源码!

五、JavaScript的用法

1、页内样式:

在HTML文件中,可以在<head>或<body>标签中添加<script>标签,然后在<script>标签中编写JavaScript代码。这种方式适合较小的脚本或者是测试阶段的代码。

例如:

<!DOCTYPE html>
<html>
<head>
<title>页内样式示例</title>
<script>
// 在这里编写JavaScript代码
</script>
</head>
<body>
// 页面内容
</body>
</html>

2、页外样式:

步骤一:在js文件夹中创建一个Xxx.js文件。

步骤二:在Xxxx.js文件中编写JavaScript代码。

步骤三:在HTML文件的<head>标签中通过<script src="Xxxx.js"></script>进行引入。
例如:

<!DOCTYPE html>
<html>
<head>
<title>页外样式示例</title>
<script src="Xxxx.js"></script>
</head>
<body>
// 页面内容
</body>
</html>

需要注意的是,引入时路径要正确,如果是当前目录则直接写文件名,如果是上级目录则需要使用./来指定路径。

  • 页外样式写到<head>中,可以让它早点加载、早点完成。

  • 而页内样式写到<body>结束标签之前,可以让HTML代码先渲染内容,然后再执行JavaScript代码。

随着Web技术的发展,JavaScript也在不断进化。ES6引入了类、模块、箭头函数等新特性,未来的JavaScri
pt将更加强大、简洁。作为前端开发的基石,也是全栈开发的重要工具,JavaScript的重要性不言而喻。

现在,你是否已经迫不及待想要开启自己的JavaScript学习之旅了呢?记住,每一位大师都是从基础开始的,不要害怕犯错,因为每一个错误都是通往成功的阶梯。

拿起你的键盘,打开你的浏览器,让我们一起在JavaScript的海洋中遨游,发现编程的无穷魅力吧!

收起阅读 »

开源项目|使用go语言搭建高效的环信 IM Rest接口(附源码)

项目背景环信 Server SDK 是对环信 IM REST API 的封装, 可以节省服务器端开发者对接环信 API 的时间,只需要配置自己的 App Key 相关信息即可使用。环信目前提供java和PHP版本的Server SDK,此项目使用go语言对环信...
继续阅读 »

项目背景

环信 Server SDK 是对环信 IM REST API 的封装, 可以节省服务器端开发者对接环信 API 的时间,只需要配置自己的 App Key 相关信息即可使用。

环信目前提供java和PHP版本的Server SDK,此项目使用go语言对环信 IM REST API 进行封装,对官方版本进行了补充,有需要的开发者可以直接通过以下地址获取源码。

项目地址

前提条件

  • go语言环境

  • 有效的环信即时通讯 IM 开发者账号和 AppKey、ClientID、ClientSecret、DomainURL
    登录 环信管理后台 到“应用列表” → 点击“查看”即可获取到 App Key、Client ID、ClientSecret,到"即时通讯" → 点击"服务概览"获取到 “Rest api” 的服务器域名。

实现方法

  • go.mod 文件引入: github.com/xiaofengin/easemob-go

AppKey、ClientID、ClientSecret在下图中获取


DomainURL在下图中获取

初始化IM SDK

package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}
}

批量注册两个用户

package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}
user1 := UserRegisterParam{
Username: "userID_1",
Password: "1",
}
user2 := UserRegisterParam{
Username: "userID_2",
Password: "1",
}
users := []UserRegisterParam{user1, user2}
ret, err := client.UserRegister(context.Background(), &users)
if err != nil {
return
}
fmt.Printf("数据的值:%v\n", ret.Entities)
}

发送一个单聊消息

  • tos 放接收方环信ID(多个)m := CreateTextMsg("hello word", tos) 创建一个消息体
  • 默认发送方ID 是 admin,如果要修改的话 m.From = "指定ID"
  • 也可以给消息添加扩展字段 m.Ext = map[string]interface{}{"key1": "value1", "key2": "value2"}
package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}
var tos []string
tos = append(tos, "环信用户ID")
m := CreateTextMsg("hello word", tos)
//m.From = "指定ID"
//m.Ext = map[string]interface{}{"key1": "value1", "key2": "value2"}
ret, err := client.SendChatMessage(context.Background(), m)
if err != nil {
return
}
fmt.Printf("数据的值:%v\n", ret.Data)
}

获取用户token

  • 通过用户ID和密码获取用户token,也可以通过用户ID获取用户token
package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}

//通过用户 ID 和密码获取用户 token
//data := TokenParam{
// GrantType: "password",
// Username: "userID",
// Password: "1",
// Ttl: "1024000",
//}

//通过用户 ID 获取用户 token
data := TokenParam{
GrantType: "inherit",
Username: "userID",
AutoCreateUser: true,
Ttl: "1024000",
}
ret, err := client.GetUserToken(context.Background(), &data)
if err != nil {
return
}
fmt.Printf("数据的值:%v\n", ret.AccessToken)
}

SDK功能清单

注意

测试代码中 appkey clientId clientSecret 这三个参数我是写到环境变量里面,
如果 你没有把参数写到环境变量里面,可以直接写死该参数


参考文档:

IMGeek社区支持:https://www.imgeek.net

收起阅读 »

移动端安全区域适配方案

web
前言 什么是安全区域? 自从苹果推出了惊艳的iPhone X,智能手机界就正式步入了全面屏的新纪元。然而,这一革新也带来了一个特别的问题——那就是屏幕顶部的“刘海”和底部的“黑条”区域。这些区域犹如手机的“神秘面纱”,遮挡了一部分屏幕,给开发者带来了新的挑战。...
继续阅读 »

前言


什么是安全区域?


自从苹果推出了惊艳的iPhone X,智能手机界就正式步入了全面屏的新纪元。然而,这一革新也带来了一个特别的问题——那就是屏幕顶部的“刘海”和底部的“黑条”区域。这些区域犹如手机的“神秘面纱”,遮挡了一部分屏幕,给开发者带来了新的挑战。


Android似乎对iPhone的设计情有独钟,纷纷效仿这种全面屏的潮流。于是,越来越多的Android手机也开始有了这个安全区域的概念。


在这个背景下,移动端安全区域适配变得尤为重要。开发者们需要巧妙地调整应用的布局和界面,确保内容不会被这些特殊区域遮挡,同时保持应用的美观和易用性。


安全区域(safe area)



安全区域定义为视图中未被导航栏、选项卡栏、工具栏或视图控制器可能提供的其他视图覆盖的区域。



ios1.png


如上图所示,安全区域为中间蓝色部分,也就是说我们在页面布局时应该保证页面内容在蓝色安全区域内。


所以对于这类机型,你如果不特殊处理,那么它将会是这样的:


ios2.png

这样就会导致底部输入框的交互受影响


网页布局方式(viewport-fit)


在处理安全区域之前,我们需要先来了解viewport-fit属性,这是解决问题的关键。


iOS带来问题的同时也带来了解决问题的方法,为了适配 iPhoneX等全面屏机型 对现有 viewport meta 标签进行了扩展,用于设置视觉视口的大小来控制裁剪区域。


用法


<meta name="viewport" content="width=device-width,initial-scale=1, user-scalable=0, viewport-fit=cover">

属性值


该属性包含三个值:



  • auto:该值不会影响初始布局视口,并且整个网页都是可见的。 UA 在视口之外绘制的内容是未定义的。它可以是画布的背景颜色,或者 UA 认为合适的任何其他颜色。(默认值,与contain表现一致)

  • contain:初始布局视口和视觉视口设置为设备显示屏中内接的最大矩形。 UA 在视口之外绘制的内容是未定义的。它可以是画布的背景颜色,或者 UA 认为合适的任何其他颜色。

  • cover:初始布局视口和视觉视口设置为设备物理屏幕的外接矩形。


区别


在非矩形显示器上(比如手表)设置视口边界框的大小时,我们必须考虑以下因素:



  • 由于视口边界框的面积大于显示器的面积而导致的剪切区域

  • 视口边界框与显示区域之间的间隙


contain


ios3.png

当使用viewport-fit: contain时,初始视口将应用于显示器的最大内接矩形。


cover


ios4.png

当使用viewport-fit: cover时,初始视口将应用于显示器的外接矩形。


env


为了解决安全区域问题,iOS 11 新增了一个新的 CSS 函数env()和四个预定义的环境变量



  • safe-area-inset-left:安全区域距离左边边界距离

  • safe-area-inset-right:安全区域距离右边边界距离

  • safe-area-inset-top:安全区域距离顶部边界距离

  • safe-area-inset-bottom:安全区域距离底部边界距离



iOS 11 中提供的 env() 函数名为 constant()。从 Safari 技术预览版 41 和 iOS 11.2 beta 开始,constant() 已被删除并替换为 env()。如有必要,您可以使用 CSS 后备机制来支持这两个版本,但以后应该更喜欢使用 env()。 —— 来自webkit文档



上面的意思是从iOS12开始不再支持使用constant函数,所以为了兼容处理,我们应该这样写:


body {
padding-bottom: constant(safe-area-inset-bottom); /* 兼容 iOS < 11.2 */
padding-bottom: env(safe-area-inset-bottom); /* 兼容 iOS >= 11.2 */
}

使用该函数的前提是必须设置meta标签viewport-fit=cover ,并且对于不支持 env() 的浏览器,浏览器将会忽略它。


适配安全区域


第一步:


修改页面布局方式


<meta name="viewport" content="width=device-width,initial-scale=1, user-scalable=0, viewport-fit=cover">

第二步:


底部适配


.keyboard_foot {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}

ios5.png

这样安全区域问题就解决了!


作者:前端南玖
来源:juejin.cn/post/7357888522333225012
收起阅读 »

一边开飞机、一边修飞机,Node 官网的重新设计

web
《前端暴走团》,喜欢请抱走~大家好,我是团长林语冰。 当当当当~惊不惊喜,意不意外,相信大家已经注意到 Node 官网的偷偷变帅了! Node 最近可谓意气风发,不仅重新设计了新官网,还有新设计的吉祥物助阵。 今天,让我们一起来深度学习 Node 官方博客,...
继续阅读 »

《前端暴走团》,喜欢请抱走~大家好,我是团长林语冰。


00-wall.png


当当当当~惊不惊喜,意不意外,相信大家已经注意到 Node 官网的偷偷变帅了!


Node 最近可谓意气风发,不仅重新设计了新官网,还有新设计的吉祥物助阵。


今天,让我们一起来深度学习 Node 官方博客,携手 Node 团队一起回顾重新设计官网的这段旅程。


00-wall.png



免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 Diving int0 the Node.js Website Redesign



规模和限制


Node 官方网站诞生已经超过 14 岁了。下载和文档主页的设计首次在 2011 年底崭露头角。这是 Node 0.6 的陈年旧事。


01-home.png


从那时起,Node 官网的规模随着项目需要与日俱增,包含了 1600 多页。在巅峰时期,它拥有大约 20 种国际化语言。Node 的域名(nodejs.org)每月处理 30 亿个请求,传输的数据量为 2 千兆字节。


错误的尝试


Node 官网首次尝试重新设计于 2019 年开始。工作从新的域名(nodejs.dev)和新存储库起步。蓦然回首,这可能从一开始就冥冥之中注定了该项目的失败。


简而言之,这个代码库不是社区或贡献者的常驻之地,也不存在已建立的贡献者工作流程。为生活奔走忙碌的人们自愿贡奉献自己的时间,但并不想学习第二套工具。该项目无法维持蒸蒸日上所需的领导力。


一边开飞机,一边修飞机


2022 年,团队回归现有的存储库,考虑如何重建站点。Node 的旧版代码库开始在各个维度上显示出它的老龄化。Node 旧版官网的设计已经 out 了。Node 旧版网站的内部结构很难扩展,而且文档也很少。


Node 团队仔细考虑了技术堆栈。正在进行的重新设计的第一阶段涉及 nextra,这是一个优秀的 Next 静态站点生成器。但随着网站的发展,我们发现自己经常“打破” nextra 的惯例,依赖于 nextra 抽象的底层 Next 模式和强大工具。


Next 是一个自然选择的进化过程,以其灵活性和强大功能而赫赫有名。举个栗子,Node 新网站仍然是为了终端用户速度和基础托管独立性而静态构建的,但利用 Next 的增量静态重新生成,来获取版本发布等动态内容。


我们与 Vercel 强强联手。当 Node 新官网的规模在静态导出时使 webpack 的内存管理紧张时,它们提供了直接支持。我们在公开发布之前对新版本进行了 Beta 测试,这是该框架的真实压力测试。


2023 年 4 月,我们进行了一次小型切换。拉取请求有 1600 个文件,将 GitHub UI 推向了渲染能力的极限。Node 新官网的基建会发生变化,但外观、内容和创作体验将保持不变。


这是一个重要的里程碑 —— 证明我们可以一边开飞机、一边修飞机。


重新设计


OpenJS 基金会慷慨解囊,全力资助 Node 团队与设计师一起进行重新设计。


设计师为 Node 新官网带来了现代化设计,其中包括用户体验流程、暗/亮模式、页面布局、移动视口注意事项和组件细分。


2-design.png


接下来是将设计实现为代码,重点放在基础设计元素和结构化组件层次结构的顺序构建上。我们从第一天起就构建了组件的变体,并从一开始就考虑了国际化。我们选择使用 Tailwind CSS,但重点是设计令牌和应用 CSS。


Orama 搜索将网站的所有内容让用户触手可及。它们对我们的静态内容进行索引,并以闪电般的速度提供 API 内容、学习材料、博客文章等结果。很难想象如果没有这个强大的搜索功能,Node 爱好者该如何方便的查阅文档。


Node 旧版官网已经国际化为近 20 种语言。虽然但是,一系列不幸的事件导致我们重置了所有翻译。


我们利用 Sentry 提供错误报告、监控和诊断工具。这对于识别问题和为我们的用户提供更好的体验大有助益。


Vercel 和 Cloudflare 支持可确保网站快速可靠。我们还通过 GitHub Actions 投资了 CI/CD 管道,为贡献者提供实时反馈。这包括使用 Chromatic、Lighthouse 结果进行视觉回归测试,确保网站质量保持较高水平。


03-ci.png


庆典开源日和黑客啤酒节


重新设计工作与 2023 年 9 月的庆典开源日以及下个月的黑客啤酒节不谋而合。我们通过将“良好的第一个 issue”作为离散的开发任务来为这些事件做好准备。就庆典开源日而言,我们还提供了现场指导,以便与会者能够以落地公关结束这一天。


仅在庆典开源日期间,就有 28 位作者提出了 40 个 PR(拉取请求)。黑客啤酒节又收到了 26 个 PR。


04-pr.png


文档


开源项目的好坏取决于它的文档。在此过程中,我们迭代或引入了:



  • 合作者指南

  • 贡献

  • README(自述文件)

  • 翻译

  • ......


新代码非常注重内联代码和配置注释、关注点分离,以及明确定义的常量。整个过程中使用 TS 可以辅助贡献者理解数据的形状和函数的预期行为。


未来规划


本次重新设计为 Node 官网的新时代奠定了基础。但工作还有待完成:



  • 将网站重新设计扩展到 API 文档。它们位于单独的代码库中,但计划将此处开发的样式移植到 API。

  • 探索网站和 API 文档的 monorepo(多库开发)。这应该可以改善重要的耦合,并减少管理两个独立代码库的开销。

  • 重新调整国际化努力。先前的翻译无法延续。我们的重量级 Markdown/MDX 方案提出了一个独特的挑战,我们正在与 Crowdin 合作解决。

  • 持续改进 CI/CD 流程。


致谢


许多人和组织为实现重新设计做出了大大小小的贡献。我们要感谢:



  • 首先也是最重要的是所有使这个项目成为可能的贡献者和合作者。

  • Chromatic 提供视觉测试平台,辅助我们审查 UI 更改,并捕获视觉回归。

  • Cloudflare 用于提供为 Node 网站、Node 的 CDN 等提供服务的基建。

  • Crowdin 提供了一个平台,使我们能够国际化 Node 官网并与译者合作。

  • Orama 提供了一个搜索平台,可以为我们的内容建立索引,并提供闪电般快速的结果。

  • Sentry 为其错误报告、监控和诊断工具提供开源许可证。

  • Vercel 提供为 Node 网站提供服务和支持的基建

  • 最后,感谢 OpenJS 基金会的支持和指导。


本期话题是 —— 你觉得 Node 的新官网颜值如何、体验如何?欢迎在本文下方自由言论,文明共享。


坚持阅读,自律打卡,每天一次,进步一点。


作者:前端暴走团
来源:juejin.cn/post/7357151301220335653
收起阅读 »

公司新来一个架构师, 将消费金融系统重构了

1、背景 1.2 业务重组与合并 随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。 1.2 解决...
继续阅读 »

1、背景


1.2 业务重组与合并


随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。


1.2 解决技术债务


现阶段存在的主要问题:



  1. 代码模块之间边界感不强,需要通过模块拆分、服务拆分来区分业务边界。

  2. 代码实现缺少层次感,设计模式单一,一层到底的冗长代码。

    此前,微服务拆分原则是按消费分期、合作方分期产品等维度进行整体拆分的,优点是明确了项目职责,简单的从需求维度进行服务拆分,确实是一种行之有效的方式,缺点是没有对基础功能进行剥离,以至于很多场景需要维护重复的代码,增加了项目的维护成本。


1.3 影响开发效率


即使我们接手项目已经有一段时间,并对项目足够了解时,但排查问题起来依然费力费时,而且系统内部代码错综复杂,调用链路交错,难以正常维护,从长远的开发效率考虑,尽快提出新型方案来代替现有结构。


1.4 监控体系不够完善


线上异常机制不够敏感,缺少关键业务指标的告警看板,作为一个业务开发,应保持对核心指标数据的敏感性。


2、重构目标



  1. 不影响业务的正常运转和迭代;

  2. 改善现有代码结构设计,让代码易于扩展,提升开发效率;

  3. 采用新工程逐步替代原有接口,旧工程逐渐废弃。


3、设计


3.1 调研


开始重构之前,调研了互联网消金通用的架构解决方案:


通用方案


由于是外部调研的通用架构设计,所以并非完全契合转转消费分期产品,但可以借鉴其分层架构设计的思想,在代码设计阶段,可以先对核心模块进行拆解和规划。


3.2 规划


前端页面与后端重构计划分两次迭代进行,分阶段进行,可以有效分摊并降低项目上线风险,第一次迭代围绕后端主要模块进行剥离重新设计并上线;第二次重构目的是解决产品需求,对接前端新页面。


3.3 修缮者模式


作为一个一线的业务开发,需要开展重构工作的同时还得保证产品需求的正常迭代,修缮者模式无疑是最佳选择。
第一次迭代历程,对于历史工程边缘逻辑保留并隔离,只对核心代码进行重构后转移到到新工程,新工程逐步接手老旧逻辑,并对老工程提供RPC接口,逐渐取代。此方案整体风险最低,同时能兼顾到正常的需求迭代。

第二次迭代历程,经历了第一次迭代后,新系统运行稳定,同时也具备接手新产品的能力,新工程开始与前端对接、联调,在此之后,V2版本也正式上线。


3.4 领域设计(横向拆分)


模块拆分



  1. 聚合业务:涵盖了消费分期主要业务,根据其各自产品需求特点,作为上层业务代码,对前端、收银台提供聚合接口。

  2. 基础服务:用户信贷所产生的数据、或依托合作方数据,围绕金融分期服务提供的数据支持。

  3. 三方对接:基于转转标准API下的逻辑实现,同时具备灵活接入合作方接口的能力。


3.5 模块设计(纵向拆分)


基于以往项目存在的问题,再结合消费分期的特点,我们对分期购买到账单还款结清的整个流程进行拆解:用户主动填写申请信息,提交授信申请并获额,挑选商品分期下单,生成还款计划,提供绑卡、账单还款等功能。以上就是一个简单的分期购物流程,基于以上流程,我们把消费分期所包含的公共模块,如授信前获额、用信、账单还款,这些富有金融服务属性的功能进行剥离。消费分期作为转转的产品原型,在聚合层中各自维护,互不影响。

设计原则:在不改变原有代码逻辑的情况下,根据单一职责和依赖倒置原则的思想:对系统进行模块拆分与合并,以明确项目职责降低耦合度;对包进行重新规划,划分包与包之间的边界,进一步减少代码间的耦合。


3.6 代码设计


好的代码重构一定离不开设计模式,基于原有单一的策略模式,我们把合作方对接模块与基础服务模块进行了拆解,采用双层模板、策略、工厂模式的组合,分别对授信、用信、贷后几个模块单独设计接口,维护好对合作方通用标准API接口,同时具备灵活接入的特点,举个例子,以下为授信模块主要代码类图:


第一层作为基础服务的策略模式;

第二层作为合作方对接的策略模式。

主要类图设计:
类图设计


在定义接口与实现类后,形成了对合作方对接层依赖,同时对订单、用信、授信等核心数据进行落地,对消费分期提供数据支撑,举个例子,以下为授信模块主要代码:



  1. 基础服务接口定义


/**
* 授信接口定义
**/

public interface ICreditService {

/**
* appId,资方定义的一个唯一ID
*/

String getAppId();

/**
* app名称
*
*
@return zz or zlj
*/

String getAppName();

/**
* 获取授信结果
*
*
@return result
*/

CreditResult creditResult(String logStr, Long uid);
}




  1. 标准流程抽象


/**
* 标准API对接实现
*
**/

public abstract class AbstractCreditService implements ICreditService {

/**
* 标准API对接
*
*
@return IBaseApiService
*/

protected abstract IBaseApiService getApiThirdService();

@Override
public AppConfig getPartner() {
return commonConfigUtil.getAppConfig(getAppId());
}

@Override
public CreditResult creditResult(String logStr, Long uid) {
CreditResultInput input = new CreditResultInput();
input.setUid(uid);
ResponseProtocol output = getApiThirdService().creditResult(logStr, input);
String creditStatus = TransformUtil.approvalStatusTransform(output.getData());
return CreditResult.builder().result(creditStatus).build();
}
}

/**
* 合作方差异化接入
*/

@Service
@Slf4j
public class ZZABCCreditServiceImpl extends AbstractABCCreditService {

@Resource
ZZABCThirdServiceImpl abcThirdService;

@Override
public String getAppId() {
return PartnerEnum.ABC_ZZ_API.getAppId();
}
@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
@Override
protected IABCThirdService getABCThirdService() {
return abcThirdService;
}
}



  1. 标准API对接


/**
* 标准API对接
*
*
@author Rouse
*
@date 2022/4/24 13:57
*/

public interface IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(CreditResultInput input);
}



  1. 内部标准API实现


/**
* 合作方,标准API对接实现
*
*
@author Rouse
*
@date 2022/4/24 14:04
*/

@Slf4j
public abstract class AbstractBaseApiService implements IBaseApiService {
@Override
public ResponseProtocol creditResult(CreditResultInput input) {
// 通用加解密
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, CreditResultOutput.class);
}
}


  1. 差异化合作方接入



/**
* ABC合作方接口封装
**/

public interface IABCThirdService extends IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(ABCCreditResultInput input);
}

/**
* 合作方抽象方法封装
**/

@Slf4j
public abstract class AbstractABCThirdService extends AbstractBaseApiService implements IABCThirdService {
@Override
public ResponseProtocol creditResult(ABCCreditResultInput input) {
// 加解密差异化实现
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, ABCCreditResultOutput.class);
}
}


/**
* ABC合作方对接
*
*/

@Service
@Slf4j
public class ZZABCThirdServiceImpl extends AbstractABCThirdService{

@Override
public String getAppId() {
return PartnerEnum.ABC_API_ZZ.getAppId();
}

@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
}


4、上线过程


对于老系统的重构,新系统上线过度期也至关重要,因为采用了新的表结构进行重新设计,涉及到数据的同步,我们采用单向数据同步,逐渐弃用老系统数据,如果灰度期间需要回滚,首先对数据进行回滚,优先保证线上服务稳定。

以下是经历两次重构迭代的过程:
迁移过程


5、监控


监控面板

告警通知



  1. 项目重构监控先行,这次我们采用了转转告警机制和Prometheus线上监控,另外搭建了一套线上看板,及时发现各个模块的潜在隐患。

  2. 日志,一个完美的系统离不开合理的日志,日志往往是定位问题最便捷的工具。


6、总结


通过此次技术重构,我们不仅解决了过去存在的技术债务问题,还提升了服务的稳定性和用户体验,也提升产品交付效率。

技术重构并非一蹴而就,但只要我们有坚定的信念和不懈的努力,终将取得成功。引用一句名言:”不要因为懒惰而拒绝重构,不要因为无暇重构而成为你拖延的理由 。” 是的,重构是持续优化代码质量和可维护性的过程,需要我们时刻关注并付诸行动。

我认为,重构的另一种价值:一个重构好的系统、往往具备通用性,可移植性。简单说就是我们重构后的系统以最小的改动且能在同行中快速复用,因为你创造了一个稳定可靠的“轮子”,如果做到这点,无非你是这个行业技术解决方案的专家。


关于作者


罗思,金融技术部后端研发工程师。转转消费分期业务开发。


作者:转转技术团队
来源:juejin.cn/post/7356550566535495732
收起阅读 »

10分钟带你用RecyclerView+PagerSnapHelper实现一个等级指示器

web
老规矩:先上最终效果图 做前端的同学在平常的工作中,很多时候都会接触到各种指示器,这次我就接到一个等级指示器的需求: RecyclerView横向滚动,item之间有分割线,中间的item会被放大,边上的item会有一定的透明度进行淡化,滚动时要将等级变更回调...
继续阅读 »

老规矩:先上最终效果图


做前端的同学在平常的工作中,很多时候都会接触到各种指示器,这次我就接到一个等级指示器的需求:

RecyclerView横向滚动,item之间有分割线,中间的item会被放大,边上的item会有一定的透明度进行淡化,滚动时要将等级变更回调给外界进行处理,停止滚动后被选中的item需要居中。


效果图如下:


1.gif


实现流程



  1. 创建一个LevelRecyclerView继承RecyclerView,在内部init方法设置它的layoutManager,在外部提供数据源与adapter,然后最简单的RecyclerView就展示出来了

  2. 给每个item添加分割线

  3. 这时候RecyclerView可以随意滚动,给它添加一个PagerSnapHelper,让RecyclerView停下来时,item可以自动居中

  4. 这时又发现首尾的item,由于滚不到RecyclerView的中间,无法被选中,于是调整首尾item的分割线长度,使它们可以滚动到RecyclerView的中间

  5. 给RecyclerView注册滚动监听,在滚动过程中动态修改item的缩放与透明度,并将当前选中等级回调出去

  6. 重写smoothScrollToPosition,方法原实现是将某个item可见,但现在的需求是居中选中,所以要重写


初始化基本的RecyclerView


在LevelRecyclerView初始化时设置一个横向的LinearLayoutManager


    init {
layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
}

设置基本的数据源与adapter


    private fun initRecycler() {
val list = mutableListOf<Int>()
list.add(R.drawable.icon_vip_level_0)
list.add(R.drawable.icon_vip_level_1)
list.add(R.drawable.icon_vip_level_2)
list.add(R.drawable.icon_vip_level_3)
list.add(R.drawable.icon_vip_level_4)
list.add(R.drawable.icon_vip_level_5)
list.add(R.drawable.icon_vip_level_6)
list.add(R.drawable.icon_vip_level_7)
list.add(R.drawable.icon_vip_level_8)
list.add(R.drawable.icon_vip_level_9)
list.add(R.drawable.icon_vip_level_10)

rv_level.adapter = object : CommonAdapter<Int>(this, R.layout.level_item, list) {
override fun convert(holder: ViewHolder, t: Int, position: Int) {
holder.setImageResource(R.id.iv_image, t)
holder.setOnClickListener(R.id.iv_image) {
rv_level.smoothScrollToPosition(position)
}
}
}
}

效果图:



给每个item添加分割线


创建一个LevelDividerItemDecoration类继承ItemDecoration,构造参数需要传入分割线的水平长度与高度,分割线的颜色为可选参数,重写getItemOffsets与onDraw方法,熟悉ItemDecoration的同学可能会觉得onDraw方法有点眼熟,因为我这个onDraw是在DividerItemDecoration上修改的


class LevelDividerItemDecoration @JvmOverloads constructor(
private val itemDividerHorizontalMargin : Int,
private val dividerHeight : Int,
dividerColor : Int = Color.parseColor("#3A3A3C")
) : ItemDecoration() {

//分割线Drawable
private val mDivider = ColorDrawable(dividerColor)
//分割线绘制区域
private val mBounds = Rect()

/**
* 计算item的分割线需要的尺寸,就是一个偏移量,可简单看成外边距
*/

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
//上下不需要分割线设置为0,左右则是将构造时传入的itemDividerHorizontalMargin设置进去
outRect.set(itemDividerHorizontalMargin, 0, itemDividerHorizontalMargin, 0)
}

/**
* 绘制分割线
*/

override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(canvas, parent, state)

canvas.save()
val top = (parent.height - dividerHeight) / 2
val bottom = top + dividerHeight
if (parent.clipToPadding) {
canvas.clipRect(parent.paddingLeft, top, parent.width - parent.paddingRight, bottom)
}

val childCount = parent.childCount
for (i in 0 until childCount) {
val item = parent.getChildAt(i)
//获取item的Rect,包含它的外边距(包含上面设置进去的偏移量)
parent.layoutManager!!.getDecoratedBoundsWithMargins(item, mBounds)

//左边分割线
mDivider.setBounds(mBounds.left, top, mBounds.left + itemDividerHorizontalMargin, bottom)
mDivider.draw(canvas)

//右边分割线
mDivider.setBounds(mBounds.right - itemDividerHorizontalMargin, top, mBounds.right, bottom)
mDivider.draw(canvas)
}
canvas.restore()
}
}

在init中添加到LevelRecyclerView


addItemDecoration(LevelDividerItemDecoration(
UIUtil.dip2px(context, 16.0),
UIUtil.dip2px(context, 4.0)))

效果图:


2.gif


这时候RecyclerView可以随意滚动,给它添加一个PagerSnapHelper,让RecyclerView停下来时,item可以自动居中


添加一个PagerSnapHelper


在内部添加一个PagerSnapHelper,并在init时设置依附于LevelRecyclerView


private val mSnapHelper = PagerSnapHelper()
init {
mSnapHelper.attachToRecyclerView(this)
layoutManager = mLayoutManager
}

效果图:


3.gif


这时又发现首尾的item,由于滚不到RecyclerView的中间,无法被选中,于是优化LevelDividerItemDecoration的计算与绘制,调整首尾item的分割线长度,使它们可以滚动到RecyclerView的中间


优化LevelDividerItemDecoration的计算与绘制


class LevelDividerItemDecoration @JvmOverloads constructor(
private val itemDividerHorizontalMargin : Int,
private val dividerHeight : Int,
dividerColor : Int = Color.parseColor("#3A3A3C")
) : ItemDecoration() {

//分割线Drawable
private val mDivider = ColorDrawable(dividerColor)
//分割线绘制区域
private val mBounds = Rect()

/**
* 计算item的分割线需要的尺寸,就是一个偏移量,可简单看成外边距
*/

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val parentWidth = parent.measuredWidth
val itemWidth = view.layoutParams.width
val lastPosition = parent.adapter?.itemCount?.minus(1) ?: 0
//针对首尾两个item计算它们的左右边距,用parentWidth - itemWidth再除2,可以使item刚好到达RecyclerView的中间
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.set(((parentWidth - itemWidth) * 0.5).toInt(), 0, itemDividerHorizontalMargin, 0)
}
lastPosition -> {
outRect.set(itemDividerHorizontalMargin, 0, ((parentWidth - itemWidth) * 0.5).toInt(), 0)
}
else -> outRect.set(itemDividerHorizontalMargin, 0, itemDividerHorizontalMargin, 0)
}
}

/**
* 绘制分割线
*/

override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(canvas, parent, state)

canvas.save()
val top = (parent.height - dividerHeight) / 2
val bottom = top + dividerHeight
if (parent.clipToPadding) {
canvas.clipRect(parent.paddingLeft, top, parent.width - parent.paddingRight, bottom)
}

//RecyclerView宽度
val parentWidth = parent.measuredWidth
val childCount = parent.childCount
for (i in 0 until childCount) {
val item = parent.getChildAt(i)
//item宽度
val itemWidth = item.measuredWidth
//获取item的Rect,包含它的外边距(包含上面设置进去的偏移量)
parent.layoutManager!!.getDecoratedBoundsWithMargins(item, mBounds)

//左边分割线
if (i == 0 && mBounds.width() > itemWidth + itemDividerHorizontalMargin * 2) {
mDivider.setBounds(0, top, mBounds.right - itemWidth - itemDividerHorizontalMargin, bottom)
} else {
mDivider.setBounds(mBounds.left, top, mBounds.left + itemDividerHorizontalMargin, bottom)
}
mDivider.draw(canvas)

//右边分割线
if (i == childCount - 1 && mBounds.width() > itemWidth + itemDividerHorizontalMargin * 2) {
mDivider.setBounds(mBounds.left + itemWidth + itemDividerHorizontalMargin, top, parentWidth, bottom)
} else {
mDivider.setBounds(mBounds.right - itemDividerHorizontalMargin, top, mBounds.right, bottom)
}
mDivider.draw(canvas)
}
canvas.restore()
}
}

效果图:


4.gif


给RecyclerView注册滚动监听,在滚动过程中动态修改item的缩放与透明度,并将当前选中等级回调出去


定义一个等级回调接口


interface OnLevelChangeListener {
fun onLevelChange(position : Int)
}

添加OnScrollListener,在滚动过程中做了一些计算,每个方法都写了注释,具体看下面代码↓


addOnScrollListener(object : OnScrollListener() {
//系数最大值
private val maxFactor = .45F

/**
* RecyclerView滚动
*/

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val first = mLayoutManager.findFirstVisibleItemPosition()
val last = mLayoutManager.findLastVisibleItemPosition()
val parentCenter = recyclerView.width / 2F
for (i in first..last) {
setItemTransform(i, parentCenter)
}
changeSnapView()
}

/**
* RecyclerView滚动状态改变
*/

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == SCROLL_STATE_IDLE) {
changeSnapView()
}
}

/**
* 对item进行各种变换
* 目前是缩放与透明度变换
*/

private fun setItemTransform(position : Int, parentCenter : Float) {
mLayoutManager.findViewByPosition(position)?.run {
val factor = calculationViewFactor(left.toFloat(), width.toFloat(), parentCenter)
val scale = 1 + factor
scaleX = scale
scaleY = scale
alpha = 1 - maxFactor + factor
}
}

/**
* 计算当前item的缩放与透明度系数
* item的中心离recyclerView的中心越远,系数越小(负相关)
*/

private fun calculationViewFactor(left: Float, width : Float, parentCenter : Float) : Float {
val viewCenter = left + width / 2
val distance = abs(viewCenter - parentCenter) / width
return max(0F, (1F - distance) * maxFactor)
}

/**
* 修改当前居中的item,把当前等级回调给外界
*/

private fun changeSnapView() {
mSnapHelper.findSnapView(mLayoutManager)?.let {
mLayoutManager.getPosition(it).let { position ->
if (lastPosition != position) {
lastPosition = position
levelListener?.onLevelChange(position)
}
}
}
}
})

给LevelRecyclerView设置等级回调监听


rv_level.levelListener = object : LevelRecyclerView.OnLevelChangeListener {
override fun onLevelChange(position: Int) {
Log.e("levelListener","levelListener $position")
tv_level.text = "等级:$position"
}
}

效果图:
1.gif


重写smoothScrollToPosition,方法原实现是将某个item可见,但现在的需求是居中选中,所以要重写


方法的原实现其实就是LinearLayoutManager内部创建了一个LinearSmoothScroller去进行滚动,现在我们创建一个CenterSmoothScroller类去继承LinearSmoothScroller,重写它的calculateDtToFit方法,calculateDtToFit用于计算滚动距离,而calculateSpeedPerPixel计算滚动速度


class CenterSmoothScroller(context: Context?) : LinearSmoothScroller(context) {

override fun calculateDtToFit(viewStart: Int, viewEnd: Int, boxStart: Int,
boxEnd: Int, snapPreference: Int)
: Int {
return boxStart + (boxEnd - boxStart) / 2 - (viewStart + (viewEnd - viewStart) / 2)
}

override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?): Float {
return super.calculateSpeedPerPixel(displayMetrics) * 3F
}
}

override fun smoothScrollToPosition(position : Int) {
if (position == lastPosition) return
if (position < 0 || position >= (adapter?.itemCount ?: 0)) return

mLayoutManager.startSmoothScroll(
CenterSmoothScroller(context).apply {
targetPosition = position
}
)
}

到这里就完成了对整个LevelRecyclerView的开发了,实现了文章开头的动画效果
1.gif


总结


10分钟过去了,这个简单的LevelRecyclerView你拿下没有?

觉得不错的话,就不要吝啬你的点赞!

需要整份代码的话,下面链接自提。

代码链接 : github.MyCustomView


作者:小白白猪
来源:juejin.cn/post/7291474028744278016
收起阅读 »

如何丝滑的实现首页看板拖拉拽功能?

web
需求简介 最近接了一个需求,需要实现不同登录人员可以自定义首页模块卡片。简单来说,就是实现首页看板模块的增添与拖拉拽,效果如下: 技术选型 原生js是支持拖拉拽的,只需要将拖拽的元素的 draggable 属性设置成 "true"即可,然后就是调用相应的函数...
继续阅读 »

需求简介


最近接了一个需求,需要实现不同登录人员可以自定义首页模块卡片。简单来说,就是实现首页看板模块的增添拖拉拽,效果如下:



技术选型


原生js是支持拖拉拽的,只需要将拖拽的元素的 draggable 属性设置成 "true"即可,然后就是调用相应的函数即可。


拖拽操作 - Web API 接口参考 | MDN


但是,原生js功能不够完善,使用起来需要改造的地方很多,因此,选用成熟的第三方插件比较好。


我们的主项目采用的是vue3,,经过一系列对比,最终选择了 vue-draggable-next这个插件。


vue-draggable-next


vue-draggable-next的周下载量月3万左右,可以看出是一个比较靠谱的插件。



它的使用方式npmj上也介绍的很详细:


vue-draggable-next


如果英文的使用Api看起来比较难受,网上还有中文的使用文档:


vue.draggable.next 中文文档 - itxst.com


这个插件也有vue2版本和纯js版本,其他框架也是也是可以完美使用的。


实现思路


需求与技术简析


根据我们的需求,我们应该实现的是分组拖拽,假设我们有三列,那我们要实现的就是这A、B、C三列数据相互拖拽。



我们看看中文官网给的示例:


vue.draggable.next group 例子


看起来很容易,我们只需要写多个draggable标签,每个draggable标签写入相同的组名即可。


实现方案


框架实现


回到代码中,要想实现一个三列可拖拉拽的模块列表,我们首先需要引入组件


<script lang="ts" setup>
import { VueDraggableNext } from 'vue-draggable-next'
// ....
</script>

然后定义一个数组储存数据:


<script lang="ts" setup>
import { VueDraggableNext } from 'vue-draggable-next'
const moduleList = ref([
{
"columnIndex": 1,
"moduleDetail": [
{ "moduleCode": "deviation", "moduleName": "控制失调空间",},
{ "moduleCode": "meeting_pending", "moduleName": "会议待办",},
{ "moduleCode": "abnormal_events", "moduleName": "异常事件", },
{ "moduleCode": "audit_matters", "moduleName": "事项审批",}
],
},
{
"columnIndex": 2,
"moduleDetail": [
{ "moduleCode": "air_conditioning_terminal", "moduleName": "空调末端", }
],
},
{
"columnIndex": 3,
"moduleDetail": [
{ "moduleCode": "run_broadcast", "moduleName": "运行播报",},
{"moduleCode": "my_schedule", "moduleName": "我的日程", },
{ "moduleCode": "cold_station", "moduleName": "冷站",}
],
}
])
</script>

最后,在代码中我们使用v-for循环渲染即可


<div v-for="moduleColumn in  moduleList " :key="moduleColumn.columnIndex" class="box">
<VueDraggableNext :list="moduleColumn.moduleDetail" group="column" >
<div v-for="(item, index) in moduleColumn.moduleDetail " :key="item.moduleCode" class="drag-item">
<!-- 模块内容 -->
</div>
</VueDraggableNext>

</div>

注意上面的html结构,我们循环渲染了三列VueDraggableNext标签,每个VueDraggableNext标签内部又通过v-for="(item, index) in moduleColumn.moduleDetail渲染了这个拖拽列内部的所有模块。我们通过group="column" 让每个VueDraggableNext组件的组名相同,实现了三个拖拽标签之间的模块互相拖拉拽。


拖拽点设置


正常情况小,我们肯定是希望在某个组件的固定位置才能拖动组件,因此我们需要使用到拖拽组件的handle属性。


vue.draggable.next属性说明:


handle:handle=".mover" 只有当鼠标在class为mover类的元素上才能触发拖到事件

根据属性说明,我们的代码实现起来也非常容易了。


  <div v-for="moduleColumn in  moduleList " :key="moduleColumn.columnIndex" class="box">
<VueDraggableNext :list="moduleColumn.moduleDetail" handle=".move" group="column">
<div v-for="(item, index) in moduleColumn.moduleDetail " :key="item.moduleCode" class="drag-item">
<div class="move">
拖拽区域
</div>
<!-- 模块内容 -->
</div>
</VueDraggableNext>

</div>

数据的增删改


实际开发中,我么一定会根据接口或者操作动态的更改列表,代码层也就是更改moduleList的值。非常幸运的是,如果你按照上面的方式写代码,当你拖拉拽完毕后,上面的moduleList值会自动更改,我们不用做任何处理!!!这么看,数据的增删改根本不是问题。


如何动态渲染组件


实际开发中,我们可能会遇到一个问题,就是如何动态的去渲染组件,如果你熟悉vue,使用动态组件component就可以实现。


首先,我们需要定义一个模块列表


import MeetingPending from '../components/meetingPending.vue'
import AbnormalEvents from '../components/abnormalEvents/index.vue'
import MySchedule from '../components/mySchedule.vue'
import TransactionApproval from '../components/transactionApproval.vue'
import RunningBroadcast from '../components/runningBroadcast.vue'
import CodeSite from '../components/codeSite/index.vue'
import MismatchSpace from '../components/mismatchSpace/index.vue'
import AirDevice from '../components/airDevice/index.vue'

// !全量模块选择列表
export const allModuleList = [
{ moduleCode: 'meeting_pending', label: '会议待办', component: MeetingPending },
{ moduleCode: 'my_schedule', label: '我的日程', component: MySchedule },
{ moduleCode: 'audit_matters', label: '事项审批', component: TransactionApproval },
{ moduleCode: 'abnormal_events', label: '异常事件', component: AbnormalEvents },
{ moduleCode: 'deviation', label: '控制失调空间', component: MismatchSpace },
{ moduleCode: 'run_broadcast', label: '运行播报', component: RunningBroadcast },
{ moduleCode: 'cold_station', label: '冷站', component: CodeSite },
{ moduleCode: 'air_conditioning_terminal', label: '空调末端', component: AirDevice }
]

然后根据moduleCode做匹配,动态渲染即可


  <div v-for="moduleColumn in  moduleList " :key="moduleColumn.columnIndex" class="box">
<VueDraggableNext :list="moduleColumn.moduleDetail" handle=".move" group="column">
<div v-for="(item, index) in moduleColumn.moduleDetail " :key="item.moduleCode" class="drag-item">
<div class="move">
拖拽区域
</div>
<component :is="getComponentsByCode(item.moduleCode)" ></component>
</div>
</VueDraggableNext>

</div>

更多定制化需求


如果上面的功能不满足你的需求,我们可以使用这个组件的其他属性,完成更多意想不到的效果


如果下面的属性说明未能完全看明,可以看左边的对应的菜单查看详细说明和例子。


属性名称说明
group如果一个页面有多个拖拽区域,通过设置group名称可以实现多个区域之间相互拖拽 或者 { name: "...", pull: [true, false, 'clone', array , function], put: [true, false, array , function] }
sort是否开启排序,如果设置为false,它所在组无法排序
delay鼠标按下多少秒之后可以拖拽元素
touchStartThreshold鼠标按下移动多少px才能拖动元素
disabled:disabled= "true",是否启用拖拽组件
animation拖动时的动画效果,如设置animation=1000表示1秒过渡动画效果
handle:handle=".mover" 只有当鼠标在class为mover类的元素上才能触发拖到事件
filter:filter=".unmover" 设置了unmover样式的元素不允许拖动
draggable:draggable=".item" 样式类为item的元素才能被拖动
ghost-class:ghost-class="ghostClass" 设置拖动元素的占位符类名,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true
chosen-class:ghost-class="hostClass" 被选中目标的样式,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true
drag-class:drag-class="dragClass"拖动元素的样式,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true
force-fallback默认false,忽略HTML5的拖拽行为,因为h5里有个属性也是可以拖动,你要自定义ghostClass chosenClass dragClass样式时,建议forceFallback设置为true
fallback-class默认false,克隆选中元素的样式到跟随鼠标的样式
fallback-on-body默认false,克隆的元素添加到文档的body中
fallback-tolerance按下鼠标移动多少个像素才能拖动元素,:fallback-tolerance="8"
scroll默认true,有滚动区域是否允许拖拽
scroll-fn滚动回调函数
scroll-fensitivity距离滚动区域多远时,滚动滚动条
scroll-speed滚动速度

传送门:vue.draggable.next 中文文档 - itxst.com


写在最后


关联文章:如何实现模块的锚点定位及闪烁提示:juejin.cn/post/734622…
240315 155754.gif


作者:石小石Orz
来源:juejin.cn/post/7346121373112811583
收起阅读 »

微信小程序主包过大终极解决方案

web
随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。 1.分包 我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具...
继续阅读 »

随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。



1.分包


我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具体如何实现可以参照官方文档这里不做过多说明。(基础能力 / 分包加载 / 使用分包 (qq.com)),但是有时候你会发现分包之后好像主包变化不是很大,这是为什么呢?



  • 痛点1:通过依赖分析,如果分包中引入了第三方依赖,那么依赖的js仍然会打包在主包中,例如echarts、wxparse、socket.io。这就导致我们即使做了分包处理,但是主包还是很大,因为相关的js都会在主包中的vendor.js

  • 痛点2:插件只能在主包中无法分包,例如直播插件直接占据1M
    image.png

  • 痛点3:tabbar页面无法分包,只能在主包内

  • 痛点4:公共组件/方法无法分包,只能在主包内

  • 痛点5:图片只能在主包内


2.图片优化


图片是最好解决的,除了tabbar用到的图标,其余都放在云上就好了,例如oss和obs。而且放在云上还有个好处就是背景图片无需担心引入不成功。


3.tabbar页面优化


这部分可以采用tabbar页面都在放在一个文件夹下,比如一共有4个tab,那么一个文件夹下就只存放这4个页面。其余tabbar的子页面一律采用分包。


4.独立分包


独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。
但是使用的时候需要注意:



  • 独立分包中不能依赖主包和其他分包中的内容,包括 js 文件、template、wxss、自定义组件、插件等(使用 分包异步化 时 js 文件、自定义组件、插件不受此条限制)

  • 主包中的 app.wxss 对独立分包无效,应避免在独立分包页面中使用 app.wxss 中的样式;

  • App 只能在主包内定义,独立分包中不能定义 App,会造成无法预期的行为;

  • 独立分包中暂时不支持使用插件。


5.终极方案we-script



我们自己写的代码就算再多,其实增加的kb并不大。大部分大文件主要源于第三方依赖,那么有没有办法像webpack中的externals一样,当进入这个页面的时候再去异步加载js文件而不被打包呢(说白了就是CDN)



其实解决方案就是we-script,他允许我们使用CDN方式加载js文件。这样就不会影响打包体积了。


使用步骤



  1. npm install --save we-script

  2. "packNpmRelationList": [{"packageJsonPath": "./package.json", "miniprogramNpmDistDir":"./dist/"}]

  3. 点击开发者工具中的菜单栏:工具 --> 构建 npm

  4. "usingComponents": {"we-script": "we-script"}

  5. <we-script src="url1" />


使用中存在的坑


构建后可能会出现依赖报错,解决的方式就是将编译好的文件手动拖入miniprogram_npm文件夹中,主要是三个文件夹:we-script,acorn,eval5


最后成功解决了主包文件过大的问题,只要是第三方依赖,都可以通过这个办法去加载。


感谢阅读,希望来个三连支持下,转载记得标注原文地址~


作者:前端小鱼26
来源:juejin.cn/post/7355057488351674378
收起阅读 »

为什么很多程序员会觉得领导没能力

相信很多人在职场里待久了,都会遇到自己觉得比较差劲的领导,这些人可能除了向上管理能力很强外(会舔老板),其他能力在你看来都挺一般,专业能力一般,超级缝合怪--上级给他的任何任务他都能分配给你们,然后他再缝合一遍完事。 那么遇到这种领导我们该怎么办呢?多数人想到...
继续阅读 »

相信很多人在职场里待久了,都会遇到自己觉得比较差劲的领导,这些人可能除了向上管理能力很强外(会舔老板),其他能力在你看来都挺一般,专业能力一般,超级缝合怪--上级给他的任何任务他都能分配给你们,然后他再缝合一遍完事。


那么遇到这种领导我们该怎么办呢?多数人想到的是跳槽,这确实是一个解法,但你跳到下家公司也保不齐会有这样的领导呀,今天咱们讨论的这个话题就先把条件限定成你不能跳槽,这个时候你该采用什么方法让自己的上班体验变好一些。


多元化自己的评估标准


首先,不能用鄙视的眼光去看待你的领导,觉得他只会舔老板(能舔、会舔也是一种很强的能力呀),有的时候你觉得你领导能力不行,很有可能是因为你的能力评估标准太单一了。


他或许在工作的某个方面不如你,但是他必定在某些方面有自己的长处,努力发现他的长处,认可他的长处,学习他的长处,可以更有助于你和他的相处,也有利于你的进步。


社会是一个大熔炉,你需要的不仅仅是业务能力和展现的舞台,也需要与社会中不同个体的和谐共处。包容、接纳,都是立身处世的能力。


学会变通和沟通,满足领导的存在感


领导之所以会在很多工作上提意见、瞎指挥、乱指挥,更多的情况可能是他知道自己对工作不熟悉,但觉得自己是领导,会有自己独特的见解,想刷自己的存在感。这种情况下,要学会满足领导的存在感。


举个例子说,你在工作中,领导过来给你提了个意见,这个意见明显是不合适的,那你就可以说,“领导,这个思路好,我们之前没往这个角度想,可以从这个角度延展一下……。”他走了,还不是我们自己把控,毕竟他只是过来刷个存在感的,只要最后的方案让客户满意,业绩给领导,把一些光环放在他身上,让他觉得他起到了作用,这些方案和他有关,他通常也不会计较了。


摸清领导管理的思想和套路


说到这里,找到领导心中的关键因素,是非常必要的。在一个项目里,员工承担的通常只是局部,而领导看的是整体,由于高度不同,所以你们考虑的关键因素是不同的。


所以你要知道领导心里到底想要的是什么,提前做好这方面的预期和准备,以及针对领导提出的你们没有考虑到的方面要虚心接受(毕竟领导跟高层接触的更多,有些战略方向上的事情他们会更清楚)。 


比如说,你是一个小编,你在意的是按时完成写作任务、及时发表、赚取眼球,而你的领导主编可能更在意的是你文章的各种数据真实性、转化人群、是否会产生舆情、是否zzzq这些。所以,要搞清领导在意的重要维度,工作才能更有效。


这里有三句话分享给大家:



  • 要能够分清你可以改变的事、无法改变的事;

  • 不去抱怨你服务改变的事;

  • 把精力用在你可以改变的地方。


你的上司,是你改变不了的,但你自己,是可以把握的。当然这篇文章也不是教你怎么委屈自己,只是提供一个不同的角度来讨论"领导不行” 这个事情,以及让你在无法立刻更换环境时,该怎样让当前的环境变得不那么恶劣。


想跳槽的同学还是应该按部就班的准备,骑驴找马有更合适的地方该跳就跳,跳过去了说不定今天学到的这些还能用的上……。


作者:kevinyan
来源:juejin.cn/post/7357911195946336293
收起阅读 »

Android RecyclerView宫格拖拽效果实现

前言 在Android发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对app厂商而言也会带来很多的曝光量,对于很多头部app,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如GridView、GridLayout,TableLa...
继续阅读 »

前言


在Android发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对app厂商而言也会带来很多的曝光量,对于很多头部app,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如GridView、GridLayout,TableLayout等,实际上,由于RecyclerView的灵活性和可扩展性很高,这些View基本没必要去学了,为什么这样说呢?主要原因是基于RecyclerView可以实现很多布局效果,传统的很多Layout都可以通过RecyclerView去实现,比如ViewPager、SlideTabLayout、DrawerLayout、ListView等,甚至连九宫格解锁效果也可以实现。


当然,在很早之前,实现网格的拖拽效果主要是通过GridView去实现的,如果列数为1的话,那么GridView基本上就实现了ListView一样的上下拖拽。


话说回来,我们现在基本不用去学习这类实现了,因为RecyclerView足够强大,通过简单的数据组装,是完全可以替代GridView和ListView的。


效果


本篇我们会使用RecyclerView来实现网格拖拽,本篇将结合图片分片案例,实现拖拽效果。


fire_139.gif


如果要实现网格菜单的拖拽,也是可以使用这种方式的,只要你的想象丰富,理论上,借助RecyclerView其实可以做出很多效果。


fire_140.gif


拖拽效果原理


拖动其实需要处理3个核心的问题,事件、图像平移、数据交换。


事件处理


实际上无论传统的拖拽效果还是最新的拖拽效果,都离不开事件处理,不过,好处就是,google为RecyclerView提供了ItemTouchHelper来处理这个问题,相比传统的GridView实现方式,省去了很多事情,如动画、目标查找等。


不过,我们回顾下原理,其实他们很多方面都是相似的,不同之处就是ItemTouchHelper 设计的非常好用,而且接口暴露的非常彻底,甚至能控制那些可以拖动、那些不能拖动、以及什么方向可以拖动,如果我们上、下、左、右四个方向都选中的话,斜对角拖动完全没问题,


事件处理这里,GridView使用的方式相对传统,而ItemTouchHelper借助RecyclerView的一个接口(看样子是开的后门),通过View自身去拦截事件.


public interface OnItemTouchListener {
//是否让RecyclerView拦截事件
boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//拦截之后处理RecyclerView的事件
void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//监听禁止拦截事件的请求结果
void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept);
}

这种其实相对GridView来说简单的多


图像平移


无论是RecyclerView和传统GridView拖动,都需要图像平移。我们知道,RecyclerView和GridView本身是通过子View的边界(left\top\right\bottom)来移动的,那么,在平移图像的时候必然不能选择这种方式,只能选择Matrix 变化,也就是transitionX和transitionY的等。不同点是GridView的子View本身并不移动,而是将图像绘制到一个GridView之外的View上,相当于灵魂附体到外面View上,实现上是比较复杂。


对于RecyclerView来说,ItemTouchHelper设计比较巧妙的一点是,通过RecyclerView#ItemDecoration来实现,在捕获可以滑动的View之后,在绘制时对View进行偏移。


class ItemTouchUIUtilImpl implements ItemTouchUIUtil {
static final ItemTouchUIUtil INSTANCE = new ItemTouchUIUtilImpl();

@Override
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive)
{
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
float newElevation = 1f + findMaxElevation(recyclerView, view);
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}

view.setTranslationX(dX);
view.setTranslationY(dY);
}
//省略一些有关或者无关的代码
}

不过,我们看到,Android 5.0的版本借助了setElevation 使得被拖拽View不被其他顺序的View遮住,那Android 5.0之前是怎么实现的呢?


其实,做过TV app的都比较清楚,子View绘制顺序可以通过下面方式调整,借助下面的方法,在TV上某个View获取焦点之后,就不会被后面的View盖住。


View#getChildDrawingOrder(...)

此方法实际上是改变了View的绘制顺序,原理是通过下面方式,将View的索引和绘制顺序进行了映射,比如原来的第一个View模式是第1个被绘制的子View,但可以变更成最后一个绘制的View。



原理:让第i个位置绘制第index的view,伪代码如下



void drawChildFunction(drawIndex,canvas){
children[mapChildIndex(drawIndex)].draw(canvas);
}

具体实现方法参考如下。


ArrayList<View> buildOrderedChildList() {
final int childrenCount = mChildrenCount;
if (childrenCount <= 1 || !hasChildWithZ()) return null;

if (mPreSortedChildren == null) {
mPreSortedChildren = new ArrayList<>(childrenCount);
} else {
// callers should clear, so clear shouldn't be necessary, but for safety...
mPreSortedChildren.clear();
mPreSortedChildren.ensureCapacity(childrenCount);
}

final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = 0; i < childrenCount; i++) {
// add next child (in child order) to end of list
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);

// 映射View
final View nextChild = mChildren[childIndex];
final float currentZ = nextChild.getZ();

// 如果Z值大的话往后移动,5.0之前的代码没有这段
int insertIndex = i;
while (insertIndex > 0 && mPreSortedChildren.get(insertIndex - 1).getZ() > currentZ) {
insertIndex--;
}
mPreSortedChildren.add(insertIndex, nextChild);
}
return mPreSortedChildren;
}

ItemTouchHelper 同样借助了此方法,在我们测试后发现,其实Android 4.4之前的版本没有明显的效果差异,但是这里依然好奇,为什么不统一使用一种方式呢?


没有找到明确的答案,但是从代码效率来说,显然setElevation性能更好一些,同时也释放了对绘制顺序的功能的占用。


private void addChildDrawingOrderCallback() {
if (Build.VERSION.SDK_INT >= 21) {
return; // we use elevation on Lollipop
}
if (mChildDrawingOrderCallback == null) {
mChildDrawingOrderCallback = new RecyclerView.ChildDrawingOrderCallback() {
@Override
public int onGetChildDrawingOrder(int childCount, int i) {
if (mOverdrawChild == null) {
return i;
}
int childPosition = mOverdrawChildPosition;
if (childPosition == -1) {
childPosition = mRecyclerView.indexOfChild(mOverdrawChild);
mOverdrawChildPosition = childPosition;
}
if (i == childCount - 1) {
//将最后索引位置展示被拖拽的View
return childPosition;
}
//后面的View 绘制顺序往前移动
return i < childPosition ? i : i + 1;
}
};
}
mRecyclerView.setChildDrawingOrderCallback(mChildDrawingOrderCallback);
}

这里为什么要讲解之前的版本怎么做的呢?主要原因是,目前除了手机设备以外,有相当一部分设备是Android 4.4 的,而且事件传递过程中需要了解这方面的思想。


数据更新


数据更新这里其实ReyclerView的优势更加明显,我们知道RecyclerView可以做到无requestLayout的局部刷新,性能更好。


@Override
public boolean onItemMove(int fromPosition, int toPosition) {
Collections.swap(mDataList, fromPosition, toPosition);
notifyItemMoved(fromPosition, toPosition);
return true;
}

不过,数据交换后还有一点需要处理,对Matrix相关属性清理,防止无法落到指定区域。


@Override
public void clearView(View view) {
if (Build.VERSION.SDK_INT >= 21) {
final Object tag = view.getTag(R.id.item_touch_helper_previous_elevation);
if (tag instanceof Float) {
ViewCompat.setElevation(view, (Float) tag);
}
view.setTag(R.id.item_touch_helper_previous_elevation, null);
}

view.setTranslationX(0f);
view.setTranslationY(0f);
}

本篇实现


以上基本都是对ItemTouchHelper的原理梳理了,当然,如果你没时间看上面的话,就看实现部分吧。


图片分片


下面我们把多张图片分割成 [行数 x 列数]数量的图片。


Bitmap srcInputBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.image_4);
Bitmap source = Bitmap.createScaledBitmap(srcInputBitmap, width, height, true);
srcInputBitmap.recycle();

int colCount = spanCount;
int rowCount = 6;

int spanImageWidthSize = source.getWidth() / colCount;
int spanImageHeightSize = (source.getHeight() - rowCount * padding/2) / rowCount;

Bitmap[] bitmaps = new Bitmap[rowCount * colCount];
for (int i = 0; i < rowCount; i++) {
for (int j = 0; j < colCount; j++) {
int y = i * spanImageHeightSize;
int x = j * spanImageWidthSize;
Bitmap bitmap = Bitmap.createBitmap(source, x, y, spanImageWidthSize, spanImageHeightSize);
bitmaps[i * colCount + j] = bitmap;
}
}

在这种过程我们一定要处理一个问题,如果我们对网格设置了边界线(ItemDecoration)且是纵向布局的话,那么RecyclerView天然都不会横向滑动,但是纵向就不一样了,纵向总高度要减去rowCount * bottomPadding,这里bottomPadding == padding/2,如下面代码。


为什么要这么做呢?因为RecyclerView计算高度的时候,需要考虑这个高度,如果不去处理,那么ReyclerView可能会滑动,虽然影响不大,但是如果实现全屏效果,拖动View时RecyclerView还能上下滑的话体验比较差。


public class SimpleItemDecoration extends RecyclerView.ItemDecoration {

public int delta;
public SimpleItemDecoration(int padding) {
delta = padding;
}

@Override
public void getItemOffsets(Rect outRect, View view,
RecyclerView parent, RecyclerView.State state)
{
int position = parent.getChildAdapterPosition(view);
RecyclerView.Adapter adapter = parent.getAdapter();
int viewType = adapter.getItemViewType(position);
if(viewType== Bean.TYPE_GR0UP){
return;
}
GridLayoutManager layoutManager = (GridLayoutManager) parent.getLayoutManager();
//列数量
int cols = layoutManager.getSpanCount();
//position转为在第几列
int current = layoutManager.getSpanSizeLookup().getSpanIndex(position,cols);
//可有可无
int currentCol = current % cols;


int bottomPadding = delta / 2;

if (currentCol == 0) { //第0列左侧贴边
outRect.left = 0;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
} else if (currentCol == cols - 1) {
outRect.left = delta / 4;
outRect.right = 0;
outRect.bottom = bottomPadding;
//最后一列右侧贴边
} else {
outRect.left = delta / 4;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
}
}
}

更新数据


这部分是常规操作,主要目的是设置LayoutManager、Decoration、Adapter以及ItemTouchHelper,当然,ItemTouchHelper比较特殊,因为其内部是ItemDecoration、OnItemTouchListener、Gesture的组合,因此封装为attachToRecyclerView 来调用。


mLinearLayoutManager = new GridLayoutManager(this, spanCount, LinearLayoutManager.VERTICAL, false);
mLinearLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup(){
@Override
public int getSpanSize(int position) {
if(mAdapter.getItemViewType(position) == Bean.TYPE_GR0UP){
return spanCount;
}
return 1;
}
});
mAdapter = new RecyclerViewAdapter();
mRecyclerView.setAdapter(mAdapter);
mRecyclerView.setLayoutManager(mLinearLayoutManager);
mRecyclerView.addItemDecoration(new SimpleItemDecoration(padding));
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new GridItemTouchCallback(mAdapter));
itemTouchHelper.attachToRecyclerView(mRecyclerView);

这里,我们主要还是关注ItemTouchHelper,在初始化的时候,我们给了一个GridItemTouchCallback,用于监听相关处理逻辑,最终通知Adapter调用notifyXXX更新View。


public class GridItemTouchCallback extends ItemTouchHelper.Callback {
private final ItemTouchCallback mItemTouchCallback;
public GridItemTouchCallback(ItemTouchCallback itemTouchCallback) {
mItemTouchCallback = itemTouchCallback;
}

@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
if(viewHolder.getItemViewType() == Bean.TYPE_GR0UP){
return 0; //设置此类型的View不可拖动
}
// 上下左右拖动
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
return makeMovementFlags(dragFlags, 0);
}

@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
// 通知Adapter移动View
return mItemTouchCallback.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
// 通知Adapter删除View
mItemTouchCallback.onItemRemove(viewHolder.getAdapterPosition());
}

@Override
public void onChildDraw(@NonNull Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
@Override
public void onChildDrawOver(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
Log.d("GridItemTouch","dx="+dX+", dy="+dY);
super.onChildDrawOver(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}

这里,主要是对Flag的关注需要处理,第一参数是拖拽方向,第二个是删除方向,我们本篇不删除,因此,第二个参数为0即可。


public static int makeMovementFlags(int dragFlags, int swipeFlags) {
return makeFlag(ACTION_STATE_IDLE, swipeFlags | dragFlags)
| makeFlag(ACTION_STATE_SWIPE, swipeFlags)
| makeFlag(ACTION_STATE_DRAG, dragFlags);
}

当然,删除和拖拽都不要的viewHolder,那么直接返回0.


总结


本篇到这里就结束了,我们利用RecyclerView实现了宫格图片的拖拽效果,主要是借助ItemTouchHelper实现,从ItemTouchHelper中我们能看到很多巧妙的的设计,里面有很多值得我们学习的技巧,特别是对事件的处理、绘制顺序调整的方式,如果做吸顶,未尝不是一种方案。


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

Android进阶之路 - 字体自适应

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获 很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种 默认执行多行显示 单行显示,不足部分显示....
继续阅读 »

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获



很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种



  • 默认执行多行显示

  • 单行显示,不足部分显示...

  • 自适应字体


静态设置


宽度是有限的,内部文字会根据配置进行自适应


在这里插入图片描述


TextView 自身提供了自适应的相关配置,可直接在layout中进行设置


主要属性



  • maxLines="1"

  • autoSizeMaxTextSize

  • autoSizeMinTextSize

  • autoSizeTextType

  • autoSizeStepGranularity


    <TextView
android:id="@+id/tv_text3"
android:layout_width="50dp"
android:layout_height="40dp"
android:layout_marginTop="10dp"
android:autoSizeMaxTextSize="18sp"
android:autoSizeMinTextSize="10sp"
android:autoSizeStepGranularity="1sp"
android:autoSizeTextType="uniform"
android:gravity="center"
android:maxLines="1"
android:text="自适应字体" />


源码:自定义属性


在这里插入图片描述




动态设置


 // 设置自适应文本默认配置(基础配置)
TextViewCompat.setAutoSizeTextTypeWithDefaults(textView, TextView.AUTO_SIZE_TEXT_TYPE_UNIFORM)
// 主动设置自适应字体相关配置
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(textView, 20, 48, 2, TypedValue.COMPLEX_UNIT_SP)



源码分析


如果你有时间,也有这方面的个人兴趣,可以一起分享学习一下


setAutoSizeTextTypeWithDefaults


根据源码来看的话,内部做了兼容处理,主要是设置自适应文本的默认配置


在这里插入图片描述


默认配置方法主要根据不同类型设置自适应相关配置,默认有AUTO_SIZE_TEXT_TYPE_NONE or AUTO_SIZE_TEXT_TYPE_UNIFORM ,如果没有设置的话就会报 IllegalArgumentException 异常



  • AUTO_SIZE_TEXT_TYPE_NONE 清除自适应配置

  • AUTO_SIZE_TEXT_TYPE_UNIFORM 添加一些默认的配置信息


在这里插入图片描述




setAutoSizeTextTypeUniformWithConfiguration


根据源码来看主传4个参数,内部也做了兼容处理,注明 Build.VERSION.SDK_INT>= 27 or 属于 AutoSizeableTextView 才能使用文字自定义适配



  • textView 需进行自适应的控件

  • autoSizeMinTextSize 自适应自小尺寸

  • autoSizeMaxTextSize 自适应自大尺寸

  • autoSizeStepGranularity 自适应配置

  • unit 单位,如 sp(字体常用)、px、dp


在这里插入图片描述


unit 有一些常见的到单位,例如 dp、px、sp等


在这里插入图片描述


作者:Shanghai_MrLiu
来源:juejin.cn/post/7247027677223485498
收起阅读 »

仿抖音评论,点击回复自动将该条评论上移至第一条

打开抖音的评论,回复评论时,自动将该条评论上滑至最上方的位置,目的也是为了让用户能够回复的时候,看到他要回复评论的内容 评论有一级评论和二级评论,一级评论是评论弹窗下的recyclerView,二级评论又是一级评论adpter中某一个item的一个recycl...
继续阅读 »

打开抖音的评论,回复评论时,自动将该条评论上滑至最上方的位置,目的也是为了让用户能够回复的时候,看到他要回复评论的内容


评论有一级评论和二级评论,一级评论是评论弹窗下的recyclerView,二级评论又是一级评论adpter中某一个item的一个recyclerView,回复一级评论时,上移相对简单一点,如果回复二级评论时,把二级评论上移到第一条稍微复杂一些。


有2种方法,目测抖音的评论就是用的一种方法


方法实现原理:先计算出该评论的在整个屏幕中的位置,主要是point Y这个点,然后计算移动到第一条需要向上移动的距离,通过属性动画完成。


具体步骤看代码注释



// 1. 拿到一级评论对应的viewHolder,有了这个viewHolder就可以拿到指定的子view
val viewHolder1 =
viewHolder!!.rvList.findViewHolderForAdapterPosition(mFirstCommentPosition)
if (viewHolder1 != null && viewHolder1 is VideoDetailCommentAdapter.VideoDetailCommentViewHolder) {
val viewHolder2 = viewHolder1 as VideoDetailCommentAdapter.VideoDetailCommentViewHolder
val location = IntArray(2)
// 如果回复一级评论,默认mSecondCommentPosition为-1,如果回复二级评论,mSecondCommentPosition肯定就不是-1了,因为position是从0开始的
if (mSecondCommentPosition != -1) {
// 拿到二级评论的viewHolder
val viewHolder3 = viewHolder2.rvList.findViewHolderForAdapterPosition(mSecondCommentPosition)
if (viewHolder3 != null && viewHolder3 is VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder) {
val viewHolder4 = viewHolder3 as VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder
// 我获取的时该评论头像相对于屏幕的的具体的点,用的是getLocationOnScreen,当然也可以参考使用# getLocationInWindow
viewHolder4.rrivAvatar.getLocationOnScreen(location)
// 获取需要上移的距离,然后上移
translateY = (location[1] - AppUtil.dp2px(264f)) * -1.0f
val ani: ObjectAnimator = ObjectAnimator.ofFloat(viewHolder!!.rvList, "translationY", 0f, translateY)
ani.duration = 300
ani.start()
}
} else {
// 我获取的时该评论头像相对于屏幕的的具体的点,用的是getLocationOnScreen,当然也可以参考使用# getLocationInWindow
viewHolder2.rrivAvatar.getLocationOnScreen(location)
// 获取需要上移的距离,然后上移
translateY = (location[1] - AppUtil.dp2px(264f)) * -1.0f
val ani: ObjectAnimator = ObjectAnimator.ofFloat(viewHolder!!.rvList, "translationY", 0f, translateY)
ani.duration = 300
ani.start()
}
}

方法二,通过scrollToPositionWithOffset实现


linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, translateY)

其中mFirstCommentPosition为一级评论的position,translateY为二级评论相对于一级评论的在竖直方向上的高度。


该方法有个小问题,不能实现缓慢的滑动效果,直接就上去了,有点突兀。


当然还有一个方法,调用recyclerView的smoothScrollToPosition方法,该方法只能实现评论滑动到屏幕可见,一般是在最下方,并不能实现滑动到顶。scrollToPositionWithOffset方法的第2个参数如果设置为0就可以实现滑动到顶。


还有一个小问题,就是如果回复的评论恰好是最后一条,则滑不上去了,因为下方没有数据了。


具体代码如下


val linearLayout = viewHolder!!.rvList.layoutManager as LinearLayoutManager

val viewHolder1 =
viewHolder!!.rvList.findViewHolderForAdapterPosition(mFirstCommentPosition)
if (viewHolder1 != null && viewHolder1 is VideoDetailCommentAdapter.VideoDetailCommentViewHolder) {
val viewHolder2 = viewHolder1 as VideoDetailCommentAdapter.VideoDetailCommentViewHolder
val location = IntArray(2)
viewHolder2.rrivAvatar.getLocationOnScreen(location)
if (mSecondCommentPosition != -1) {
val viewHolder3 = viewHolder2.rvList.findViewHolderForAdapterPosition(mSecondCommentPosition)
if (viewHolder3 != null && viewHolder3 is VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder) {
val viewHolder4 = viewHolder3 as VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder
val location1 = IntArray(2)
viewHolder4.rrivAvatar.getLocationOnScreen(location1)
translateY = (location1[1] - location[1]) * -1
linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, translateY)
}
} else {
linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, 0)
}
}

作者:心在梦在
来源:juejin.cn/post/7356772896046415908
收起阅读 »

既然有需求,那么就手撸一个JSON可视化组件吧

前言: 最近在公司的一个项目中遇到了一个需求,就是将读取到的JSON数据,展示成一个树形结构,并且还得给每一个节点添加一个类型标签以及复选框。好了,话不多说,直接上代码,代码的思路到时候放在代码后面。如果各位看官老爷觉得有什么可以优化的地方或者不理解的地方也可...
继续阅读 »

前言:


最近在公司的一个项目中遇到了一个需求,就是将读取到的JSON数据,展示成一个树形结构,并且还得给每一个节点添加一个类型标签以及复选框。好了,话不多说,直接上代码,代码的思路到时候放在代码后面。如果各位看官老爷觉得有什么可以优化的地方或者不理解的地方也可以在评论中说出,大家一起讨论;


代码


<script>
export default {
name: "YJsonEditer",
data() {
return {
JsonAST: {},
LiList: [],
checkBoxList: [],
checkBoxKey: 1,
// checkId:''
};
},
props: {
json: {
type: String,
default:
'{"total":18,"data":[[{"themeType":{"themeType":"dark"}}],[{"themeType":"light"}]],"rows":[{"caseCode":"9174ff6dfbc243eb931270a06c447666","institutionId":2,"arbitralCourtId":1,"nickName":"张慧","deptId":102,"applicantName":"钱红","arbitralCourtName":"第一仲裁庭","times":2,"caseNumber":"常钟劳人仲案字〔2023〕第29号","scheduleDate":"2023-05-30T00:00:00","caseName":"钱红讨薪","respondentName":"钱橙","startTime":"08:30:00","id":26,"endTime":"08:45:00","status":"1"},{"caseCode":"1c096b703b78495ea90ca13ab65258cb","institutionId":2,"arbitralCourtId":1,"nickName":"徐洋","deptId":102,"applicantName":"赵春","arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第28号","scheduleDate":"2023-05-25T00:00:00","caseName":"赵春徐洋登记","respondentName":"赵夏","startTime":"08:30:00","id":24,"endTime":"08:45:00","status":"3"},{"caseCode":"9174ff6dfbc243eb931270a06c447666","institutionId":2,"arbitralCourtId":1,"nickName":"张慧","deptId":102,"applicantName":"钱红","arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第29号","scheduleDate":"2023-05-25T00:00:00","caseName":"钱红讨薪","respondentName":"钱橙","startTime":"09:00:00","id":25,"endTime":"09:15:00","status":"3"},{"caseCode":"47cdbb573f354660b448d3bf55d36a69","arbitralCourtId":1,"nickName":"徐洋","applicantName":"赵春","arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第26号","scheduleDate":"2023-05-24T00:00:00","caseName":"劳动报酬","respondentName":"赵夏","startTime":"08:30:00","id":23,"endTime":"08:45:00","status":"3"},{"caseCode":"e3a68febb9294112a85dfeffc00002d6","arbitralCourtId":1,"nickName":"周圆","applicantName":"申请人加密","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"被申请单位加密","times":3,"caseNumber":"常钟劳人仲案字〔2023〕第12号","scheduleDate":"2023-05-23T00:00:00","caseName":"测试加密1111","startTime":"18:30:00","id":22,"endTime":"18:45:00","status":"3"},{"caseCode":"e3a68febb9294112a85dfeffc00002d6","arbitralCourtId":1,"nickName":"周圆","applicantName":"申请人加密","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"被申请单位加密","times":2,"caseNumber":"常钟劳人仲案字〔2023〕第12号","scheduleDate":"2023-05-19T00:00:00","caseName":"测试加密1111","startTime":"08:45:00","id":21,"endTime":"09:15:00","status":"2"},{"caseCode":"9ae48862deaa4c24982b8bef5993d00d","arbitralCourtId":1,"nickName":"徐洋","applicantName":"数据库加密申请人1,申请人2","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"数据库加密单位","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第11号","scheduleDate":"2023-05-12T00:00:00","caseName":"加密测试案件","startTime":"09:00:00","id":19,"endTime":"09:30:00","status":"1"},{"caseCode":"e3a68febb9294112a85dfeffc00002d6","arbitralCourtId":1,"nickName":"周圆","applicantName":"申请人加密","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"被申请单位加密","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第12号","scheduleDate":"2023-05-12T00:00:00","caseName":"测试加密1111","startTime":"12:30:00","id":20,"endTime":"15:00:00","status":"1"},{"caseCode":"73c606f4dfca4137b3c0f4ee6ed4b9f9","institutionId":2,"arbitralCourtId":1,"nickName":"姜哲","deptId":102,"arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第3号","scheduleDate":"2023-05-05T00:00:00","caseName":"赔偿医疗费","startTime":"08:30:00","id":18,"endTime":"08:45:00","status":"3"},{"caseCode":"793855c4cab545099752d7b4dd2ef402","times":1,"arbitralCourtId":1,"caseNumber":"常钟劳人仲案字〔2023〕第10号","nickName":"刘祥任","scheduleDate":"2023-05-04T00:00:00","caseName":"某某公司拖欠工资","startTime":"09:00:00","id":16,"endTime":"09:30:00","arbitralCourtName":"第一仲裁庭","status":"1"}],"code":200,"msg":"查询成功"}',
},
height: {
type: String,
default: "100%",
},
width: {
type: String,
default: "100%",
},
checkId: {
type: String,
default: "",
},
isEdit: {
type: Boolean,
},
},
methods: {
/**
* @description 初始化JSON数据,将其变成AST树;
*/
initJSON() {
// console.log(this.json)
if (!this.json) return;
let jsonObj = JSON.parse(this.json);
this.JsonAST = {
label: "",
type: "Object",
_id: "0",
isExpand: true,
children: this.JsonRecursionToAst(jsonObj, "0"),
};
},
/**
* @description 递归处理JSON数据,返回AST树;
*/
JsonRecursionToAst(jsonVal, _parentId, _parentKey) {
let _type = this.getType(jsonVal);
let currentArr = [];
// 通过传入类型决策使用
let _typeDecision = {
Array: () => {
if (jsonVal.length != 0) {
jsonVal.forEach((item, idx) => {
let current = {
label: String(idx),
_id: `${_parentId}-${idx}`,
type: this.getType(item),
_key: `${_parentKey}[${idx}]`,
};
if (current.type == "Object" || current.type == "Array") {
current.isExpand = true;
current.children = this.JsonRecursionToAst(
item,
current._id,
current._key
);
}
currentArr.push(current);
});
}
return currentArr;
},
Object: () => {
let currentKeys = Object.keys(jsonVal);
if (currentKeys.length != 0) {
currentKeys.forEach((key, idx) => {
let current = {
label: key,
_id: `${_parentId}-${idx}`,
type: this.getType(jsonVal[key]),
_key: `${_parentKey ? _parentKey + "." : ""}${key}`,
};
if (current.type == "Object" || current.type == "Array") {
current.isExpand = true;
current.children = this.JsonRecursionToAst(
jsonVal[key],
current._id,
current._key
);
}
currentArr.push(current);
});
}
return currentArr;
},
};
return _typeDecision[_type]();
},

/**
* @description 将AST语法树转换为显示List
*/
AstRecursionToList(AstTree) {
let list = [];
let _type = this.getType(AstTree);
let _typeDecision = {
Array: () => {
AstTree.forEach((_node) => {
const { label, type, _id, _key } = _node;
if (type == "Array" || type == "Object") {
const { isExpand, children } = _node;
if (isExpand) {
const chileList = this.AstRecursionToList(children) ?? [];
chileList.unshift({
label,
type,
_id,
isExpand,
_key,
});
chileList.push({
endTag: true,
_id,
type,
});
list = list.concat(chileList);
} else {
list.push({ label, type, _id, isExpand, children, _key });
}
} else {
list.push({ label, type, _id, _key });
}
});
return list;
},
Object: () => {
const { label, type, _id, _key } = AstTree;
if (type == "Array" || type == "Object") {
const { isExpand, children } = AstTree;
// 如果展开标志位为true,则继续递归,否则不进行递归
if (isExpand) {
const chileList = this.AstRecursionToList(children) ?? [];
chileList.unshift({
label,
type,
_id,
isExpand,
_key,
});
chileList.push({
endTag: true,
type,

_id,
});
list = list.concat(chileList);
} else {
list.push({ label, type, _id, isExpand, _key });
}
} else {
list.push({ label, type, _id, _key });
}
return list;
},
};
return _typeDecision[_type] && _typeDecision[_type]();
},
/**
* @descripotion 修改AST语法树中isExpand状态
*/
changeIsExpandInAst(tree, id) {
let _type = this.getType(tree);
let _typeDecision = {
Array: () => {
tree.forEach((node) => {
const { type, _id } = node;
if (_id == id) {
node.isExpand = !node.isExpand;
return;
}
// 如果当前层级拥有子级,并且id前缀可以匹配,则进行递归
if (
(type == "Object" || type == "Array") &&
Object.hasOwnProperty.call(node, "children") &&
id.indexOf(_id) > -1
) {
this.changeIsExpandInAst(node.children, id);
}
});
},
Object: () => {
const { _id } = tree;
// 如果匹配,则直接修改状态并返回
if (_id == id) {
tree.isExpand = !tree.isExpand;
return;
}
// 如果当前层级拥有子级,并且id前缀可以匹配,则进行递归
if (Object.hasOwnProperty.call(tree, "children") && id.indexOf(_id) > -1) {
this.changeIsExpandInAst(tree.children, id);
}
},
};
_typeDecision[_type]();
},
/**
* @description 获取数据类型
*/
getType(val) {
const type = Object.prototype.toString
.call(val)
.replace("[object ", "")
.replace("]", "");
return type == "Null" ? "String" : type;
},
/**
* @description 获取缩进长度,默认靠左多2em
*/
getIndentation(id) {
return id?.split("-")?.length * 2 ?? 2;
},
getCheckStatus(id) {
const flag = this.checkBoxList.filter((checkBox) => checkBox == id).length != 0;
if (flag) {
return true;
} else {
return false;
}
},
checkChange(e, id, key, label) {
if (Array.from(id).length == 0) return;
this.checkBoxKey += 1;
if (e) {
if (this.checkBoxList.length == 0 || this.checkBoxList.length == 1) {
this.checkBoxList = this.createIdsFromNodeId(id);
} else {
// 设置是否允许修改标志位
let canEdit = true;
let checkList = this.createIdsFromNodeId(id);
if (checkList.length < this.checkBoxList.length) {
this.$message.warning("请先取消已选中的同级字段,在进行勾选");
return;
}
for (let index = 0; index < this.checkBoxList.length - 1; index++) {
const element = this.checkBoxList[index];
if (element != checkList[index]) {
canEdit = false;
}
}
if (canEdit) {
this.checkBoxList = checkList;
} else {
this.$message.warning("请先取消已选中的同级字段,在进行勾选");
}
}
} else {
let canEdit = this.checkBoxList.filter((box) => box == id).length != 0;
if (canEdit) {
let checkBoxList = this.createIdsFromNodeId(id);
checkBoxList.pop();
this.checkBoxList = checkBoxList;
} else {
this.$message.warning("请先取消已选中的同级字段,在进行勾选");
}
}
const c_id = this.checkBoxList?.slice(-1)[0];
c_id == this.checkId ? "" : this.getKeyAndId(c_id);
},
//返回path id
getKeyAndId(id) {
const obj = this.LiList.find((item) => item._id == id);
obj ? this.$emit("change", { path: obj._key, id: obj._id }) : "";
},
/**
* @description 根据id生成数组
*/
createIdsFromNodeId(id) {
let list = [];
id.split("-").forEach((item) => {
if (list.length == 0) {
list.push(item);
} else {
list.push(`${list[list.length - 1]}-${item}`);
}
});
return list;
},
/**
* 预览模式设置选中元素
*/
setcheckId() {
const { checkId } = this;
this.checkChange(true, checkId ?? "");
},
},
watch: {
checkId(e) {
console.log(e);
},
json: {
handler(val) {
if (typeof val == "string") {
this.initJSON();
} else {
// 如果传入的值不为JSON格式,抛异常
console.error("inputDataType not JSON!");
}
},
immediate: true,
deep: true,
},
JsonAST: {
handler(val) {
this.LiList = this.AstRecursionToList(val);
this.$nextTick(() => {
this.setcheckId();
});
},
immediate: true,
deep: true,
},
},
render() {
// 单独生成一行html
let singLineHtml = (_node) => {
// const {label, type, _id} = _node;
return (
<div style={{ textIndent: `${this.getIndentation(_node._id)}em` }}>
{createLabel(_node)}
{createTypeTag(_node)}
{createExpandTag(_node)}
{createBracket(_node)}
{createCheckBox(_node)}
</div>
);
};
let createLabel = (_node) => {
if (Object.hasOwnProperty.call(_node, "label") && _node.label?.length != 0) {
return <span>{`"${_node.label}":`}</span>;
}
};
let createExpandTag = (_node) => {
if (Object.hasOwnProperty.call(_node, "isExpand")) {
const { isExpand, _id } = _node;
return (
<i
onClick={() => {
this.changeIsExpandInAst(this.JsonAST, _id);
}}
style="color:#ff9a00;"
class={
isExpand
? "el-icon-remove-outline expandTag"
: "el-icon-circle-plus-outline expandTag"
}
></i>
);
}
};
let createTypeTag = (_node) => {
if (!Object.hasOwnProperty.call(_node, "endTag")) {
const { type } = _node;
return <span class={`tag ${type}-tag`}>{type}</span>;
}
};
let createBracket = (_node) => {
const { type } = _node;
if (Object.hasOwnProperty.call(_node, "endTag")) {
if (type == "Array") {
return (
<span>
<span class="bracket-array">{"]"}</span>,
</span>
);
} else {
return (
<span>
<span class="bracket-object">{"}"}</span>,
</span>
);
}
} else if (Object.hasOwnProperty.call(_node, "isExpand")) {
const { isExpand } = _node;
// 展开
let expandStrategy = {
Array: () => {
return (
<span>
<span class="bracket-array">{"["}</span>
</span>
);
},
Object: () => {
return (
<span>
<span class="bracket-object">{"{"}</span>
</span>
);
},
};
// 关闭
let notExpandStrategy = {
Array: () => {
return (
<span>
<span class="bracket-array">{`[${_node.children.length}]`}</span>,
</span>
);
},
Object: () => {
return (
<span>
<span class="bracket-object">{"{...}"}</span>,
</span>
);
},
};
return isExpand ? expandStrategy[type]() : notExpandStrategy[type]();
} else {
return <span>,</span>;
}
};
let createCheckBox = (_node) => {
if (!Object.hasOwnProperty.call(_node, "endTag")) {
const { _id, _key, label } = _node;
return (
<el-checkbox
key={this.checkBoxKey}
style={this.isEdit ? "pointer-events:none" : ""}
value={this.getCheckStatus(_id)}
onChange={(e) => {
this.checkChange(e, _id, _key, label);
}}
></el-checkbox>
);
}
};
return (
<ul
class="json-container"
style={{ height: this.height, width: this.width }}
ref="JsonContainer"
>
<div class="json-index"></div>
{this.LiList.map((node, idx) => {
return (
<li>
<span class="line-head">{idx + 1}</span>
{singLineHtml(node)}
</li>
);
})}
</ul>
);
},
};
</script>

<style lang="scss" scoped>
.json-container {
background-color: #fff;
overflow: auto;
color: #767676;
position: relative;
.json-index {
width: 44px;
height: 100%;
background: #f5f5f5;
position: absolute;
top: 0;
left: 0;
// border-right:1px solid #E7E7E7;
}
li {
height: 35px;
line-height: 35px;
display: flex;
font-size: 14px;
position: relative;
.line-head {
display: inline-block;
height: 35px;
line-height: 35px;
width: 44px;
box-sizing: border-box;
font-size: 14px;
text-align: center;
border: solid #e7e7e7;
border-width: 0 1px 1px 0;
background: #f5f5f5;
// &:last-of-type{
// border-width: 0 1px 0px 0;
// }
}
}
}
.tag {
box-sizing: border-box;
padding: 3px 7px;
display: initial;
border-radius: 4px;
margin-left: 2px;
margin-right: 2px;
color: #fff;
background-color: #c9c9c9;
font-size: 12px;
}
.Object-tag {
background-color: #ff9a00;
}
.Number-tag {
background-color: #2b7cf5;
}
.String-tag {
background-color: #00a870;
}
.Array-tag {
background-color: #a17bd1;
}
.bracket-object {
color: #ff9a00;
}
.bracket-array {
color: #a17bd1;
}
.expandTag {
cursor: pointer;
display: initial !important;
}
::v-deep.el-checkbox {
display: initial;
.el-checkbox__input {
display: initial;
}
// .el-checkbox__input.is-disabled.is-checkId .el-checkbox__inner{
// background: #2B7CF5;
// &::after{
// border-color: #FFF;
// }
// }
}
</style>


思维导图


微信截图_20240415152236.png


主要函数解读


JsonRecursionToAst


这个方法是递归处理传入的JSON数据,首先通过getType方法获取当前节点的数据类型【getType中通过原型链方式获取数据类型】,根据数据类型决策返回数据【主要节点参数是label(key),_id(节点ID),type(节点数据类型),其余的参数为业务需求】;


AstRecursionToList


直接通过AST树去生成DOM结构的话我觉得会比较复杂,所以我就在AST树与DOM结构之间写了此方法,这个方法去监听AST树,只要树发生了变化便会执行次方法;首先进入这个方法后根据节点类型去决策进入哪个结构,然后根据isExpand判断是否展开,若不展开则直接不向下执行,若展开,则继续递归;


changeIsExpandInAst


此方法是修改展开状态的,通过_id进行匹配,只要前缀相同,则递归,直至完全相同修改状态;


checkChange


这个方法是业务逻辑的需求,可以点击节点前的复选框,但是不允许点击不同分支的复选框;


作者:用户352970449145
来源:juejin.cn/post/7357698682744143911
收起阅读 »

从字节到小县城做三份远程工作,区块链技术给我带来了什么?

2022年,我决定离开字节跳动,这一决定标志着我职业生涯的一次重大转变。我转向了区块链行业,开始了远程工作和数字游民生活。我的经历不仅是个人转型的故事,更是关于程序员如何借助区块链赛道的发展机会,摆脱国内互联网的过度内卷,像我一样成长为数字游民。 结合我自己的...
继续阅读 »

2022年,我决定离开字节跳动,这一决定标志着我职业生涯的一次重大转变。我转向了区块链行业,开始了远程工作和数字游民生活。我的经历不仅是个人转型的故事,更是关于程序员如何借助区块链赛道的发展机会,摆脱国内互联网的过度内卷,像我一样成长为数字游民。


结合我自己的经历,给大家分享的是作为程序员,如何从传统互联网行业到区块链行业的转型,特别是针对想要从事远程工作的开发同学分享一些我的思考和路线建议。



(边旅行边远程)


区块链是什么?


区块链技术自2008年比特币的问世以来,已经展示了其在多个领域的应用潜力,包括金融、供应链、医疗和艺术等。到今天,区块链不仅限于加密货币,智能合约和去中心化应用(DApps)已经成为推动技术前进的新动力。


为什么在区块链领域,一定要看海外的发展机遇?


在国外,特别是在欧美,区块链技术得到了飞速发展。例如,美国、瑞士和新加坡等国家已经制定友好的区块链政策,吸引了大量的区块链创业公司和投资。海外的区块链开发通常能享受到更加宽松的监管环境和更多的资金支持,这些因素共同促成了一个成熟的区块链生态系统。


在我们国家,大多数区块链企业会选择在香港。对于我们来说,如果短时间英文口语不太能提升,可以多关注香港、新加坡的远程机会。企业和项目还是非常多。



(在安吉DNA)


学习区块链是选老牌公链还是新链?


区分老牌公链和新链是入门并理解区块链的关键。


老牌公链如比特币和以太坊,已经建立了坚实的基础,拥有庞大的用户和开发者社群。新链,如Solana和Polkadot,虽然起步较晚,但在技术创新(例如更快的交易处理速度和更低的手续费)和特定应用场景上可能有更具吸引力的优势。


对于开发而言,选择专注于老牌公链还是新链,应根据个人的兴趣和市场需求来决定:



  • 老牌公链开发:更适合那些喜欢稳定、渐进式创新和长期投资的开发同学。

  • 新链开发:适合追求技术创新边界,愿意在新兴技术上投入时间和精力的开发者。


作为区块链的从业者,我建议新手还是先学一下老牌公链技术,对你后续的发展更有利,也相对更容易转型。


不同技术栈如何转型区块链


对于希望从传统技术栈转型至区块链开发的程序员,每个技术栈的转型路径都会有所不同。我给大家列了一个详细的指南,包括推荐的步骤、大概所需的时间周期以及可能的目标岗位。这样的规划旨在为有志于区块链领域的开发者提供清晰的职业发展路径。



(远程公司寄来的礼物)


一、区块链转型指南


我用一张表详细描述了从不同web2技术栈转型至区块链开发的具体步骤、周期和目标岗位:


3.png



  • 智能合约 开发:对于绝大多数开发同学来说,转型区块链开发,建议先学区块链技术原理集合智能合约,就业方向是智能合约开发。

  • 区块链前/后端开发:如果具备web经验,比如前端、后端Go/Node/Java等,建议转区块链后端开发。



    • Java的解决方案:Java技术栈同学可以先用Java技术栈拆解区块链项目,然后直接去求职。工作中再切换语言。语言难度并不大。

    • 对于前端同学,如果原本不具备后端 Node 能力,那就学 DApp 前端,也够用。



  • 相对转型周期更长的技术栈:C#、Android、iOS、Php、Python大数据、算法、运维开发等。



    • 直接转智能合约也可以,但是建议可以先补充点 web 开发能力,这样更好就业,这件事也可以通过我们训练营的资源。

    • 其次,你同时学习区块链基础课程和任务,包括智能合约相关课程并不冲突。只是在第六周之后的项目实战阶段,会比较依赖你的web开发能力,否则项目任务没办法完成。




二、转型步骤解析


Step1 学习 区块链技术 原理,建立认知

对所有技术栈的开发同学来说,第一步都是建立区块链技术的基础知识,理解其工作原理、主要特性以及实际应用场景。


Step2 技术深入和框架学习

不同的技术栈应侧重于学习与其专业相关的区块链技术。例如,Java开发可以专注于区块链后端开发,最好掌握主流区块链框架,而前端开发则应学习如何通过Web3.js等库与区块链交互。


Step3 项目实战

理论学习后,参与实际项目是检验和加深技术理解的最好方式。实战经验不仅能帮助巩固学习成果,也是提高市场竞争力的关键,帮助求做好铺垫



(在数字游民公社)


远程工作带来的机遇


远程工作为程序员提供了前所未有的自由度。我在小县城通过远程工作不仅实现了生活和工作的平衡,还能参与到全球项目中。


远程也带来了挑战,尤其是在沟通和协作方面。对于非英语母语的开发同学,提升英语能力是关键,这包括进行模拟面试、加强专业术语的使用,以及在实际工作中不断实践英语沟通。


建立远程工作社群


基于我这些年的经验,以及我曾经合作过的身边大厂同学转型的案例。我们正在建立一个远程工作社群,这不仅是一个远程工作技术交流的平台,更是相互支持和资源共享的社区。


我们希望通过这个社群帮助更多开发者顺利过渡到区块链领域,尤其是那些对远程工作感兴趣的朋友们。


最后要说的话


转型的道路充满了不确定性和挑战,但也充满了机遇。从字节跳动辞职,到在小县城同时做三份远程工作,我的经历或许能给正在考虑转型的你一些启示和勇气。


区块链不仅是技术的革新,更是职业生涯的一个新起点。如果你对区块链感兴趣,或许现在是跨入这个领域的最好时机。



作者:C2N数字游民部落
来源:juejin.cn/post/7356326955929075712
收起阅读 »

Android项目——LittlePainter

一、项目简介 项目采用 Kotlin 语言编写,结合 Jetpack 相关控件,Navigation,Lifecyle,DataBinding,LiveData,ViewModel等搭建的 MVVM 架构模式 通过组件化拆分,实现项目更好解耦和复用 自定义v...
继续阅读 »

一、项目简介



  • 项目采用 Kotlin 语言编写,结合 Jetpack 相关控件,NavigationLifecyleDataBindingLiveDataViewModel等搭建的 MVVM 架构模式

  • 通过组件化拆分,实现项目更好解耦和复用

  • 自定义view

  • recycleview的使用

  • 移动+淡入动画:补间动画

  • 播放Lottie资源

  • 项目截图
    445D49437D72307AB01FB87DD2E6D34C.jpg


D703A49583FAAFFD5449F21392BC0090.jpg


AF0F008861A540D2EB86B075C98372C7.jpg


16417F10BD034AEEE47BFA624D8590B2.jpg


439E66BE976571C244087AB01B38EE3F.jpg
github github.com/afbasfh/Lit…


二、项目详情


2.1 MVVM(Model-View-ViewModel)


是一种基于数据绑定的架构模式,用于设计和组织应用程序的代码结构。它将应用程序分为三个主要部分:Model(模型)、View(视图)和ViewModel(视图模型)。



  • Model(模型):负责处理数据和业务逻辑。它可以是从网络获取的数据、数据库中的数据或其他数据源。Model层通常是独立于界面的,可以在多个界面之间共享。

  • View(视图):负责展示数据和与用户进行交互。它可以是Activity、Fragment、View等。View层主要负责UI的展示和用户输入的响应。

  • ViewModel(视图模型):连接View和Model,作为View和Model之间的桥梁。它负责从Model中获取数据,并将数据转换为View层可以直接使用的形式。ViewModel还负责监听Model的数据变化,并通知View进行更新。ViewModel通常是与View一一对应的,每个View都有一个对应的ViewModel。


image.png


2.2 Jetpack组件


(1) Navtgation


Google 在2018年推出了 Android Jetpack,在Jetpack里有一种管理fragment的新架构模式,那就是navigation. 字面意思是导航,但是除了做APP引导页面以外.也可以使用在App主页分tab的情况.. 甚至可以一个功能模块就一个activity大部分页面UI都使用fragment来实现,而navigation就成了管理fragment至关重要的架构.


这里主要用于页面的切换


(2) ViewBinding&DataBinding



  • ViewBinding 的出现就是不再需要写 findViewById()

  • DataBinding 是一种工具,它解决了 View 和数据之间的双向绑定;减少代码模板,不再需要写findViewById()释放 Activity/Fragment,可以在 XML 中完成数据,事件绑定工作,让 Activity/Fragment 更加关心核心业务;数据绑定空安全,在 XML 中绑定数据它是空安全的,因为 DataBinding 在数据绑定上会自动装箱和空判断,所以大大减少了 NPE 问题。


(3) ViewModel


ViewModel 具备生命感知能力的数据存储组件。页面配置更改数据不会丢失,数据共享(单 Activity 多 Fragment 场景下的数据共享),以生命周期的方式管理界面相关的数据,通常和 DataBinding 配合使用,为实现 MVVM 架构提供了强有力的支持。


(4) LiveData


LiveData 是一个具有生命周期感知能力的数据订阅,分发组件。支持共享资源(一个数据支持被多个观察者接收的),支持粘性事件的分发,不再需要手动处理生命周期(和宿主生命周期自动关联),确保界面符合数据状态。在底层数据库更改时通知 View。


(5) Room


一个轻量级 orm 数据库,本质上是一个 SQLite 抽象层。使用更加简单(Builder 模式,类似 Retrofit),通过注解的形式实现相关功能,编译时自动生成实现类 IMPL


这里主要用于收藏点赞音乐,与 LiveData 和 Flow 结合处理可以避免不必要的 NPE,可以监听数据库表中的数据的变化,也可以和 RXJava 的 Observer 使用,一旦发生了 insert,update,delete等操作,Room 会自动读取表中最新的数据,发送给 UI 层,刷新页面。


2.3 RecycleView


1.1 什么是RecycleView


Recyclerview是可以展示大量数据 ,重视回收和复用的view的一种控件;
RecyclerView是一个强大的滑动组件,与经典的ListView相比,同样拥有item回收复用的功能,这一点从它的名字Recyclerview即回收view也可以看出。RecyclerView 支持 线性布局、网格布局、瀑布流布局 三种,而且同时还能够控制横向还是纵向滚动。


1.2RecycleView的用法


纵向排列 布局文件:

1 .创建主布局并在主布局中添加 代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:context=".home.HomeFragment">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="400dp"
android:layout_marginTop="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />



</androidx.constraintlayout.widget.ConstraintLayout>

2.创建子项布局文件addressbook_item.xml,并对其内部控件设置id 代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="match_parent">

<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView"
android:layout_width="250dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/f1" />

<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="@drawable/picture_border"
app:layout_constraintBottom_toBottomOf="@+id/imageView"
app:layout_constraintEnd_toEndOf="@+id/imageView"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView" />
</androidx.constraintlayout.widget.ConstraintLayout>

3.创建适配器

直接继承RecyclerView.Adapter<AddressBookAdapter.ViewHolder> 然后一一实现


package com.example.littlepainter.home

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGr0up
import androidx.navigation.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.example.littlepainter.db.Picture
import com.example.littlepainter.databinding.LayoutPictureItemBinding

class PictureAdapter: RecyclerView.Adapter<PictureAdapter.MyViewHolder>() {
private var mPictures = emptyList<Picture>()

override fun getItemCount(): Int {
return mPictures.size
}

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): MyViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = LayoutPictureItemBinding.inflate(inflater,parent,false)
return MyViewHolder(binding)
}

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.bind(mPictures[position])
}

@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<Picture>){
mPictures = newData
notifyDataSetChanged()
}

//定义ViewHolder
class MyViewHolder(private val binding:LayoutPictureItemBinding):RecyclerView.ViewHolder(binding.root){
fun bind(pictureModel: Picture){
binding.imageView.setImageBitmap(pictureModel.thumbnail)
binding.root.setOnClickListener {
//切换到绘制界面
val action = HomeFragmentDirections.actionHomeFragmentToDrawFragment(pictureModel)
binding.root.findNavController().navigate(action)
}
}
}
}

4.在活动中创建并设置适配器


binding.recyclerView.apply {
layoutManager = ScaleLayoutManager(requireContext())
adapter = mAdapter
PagerSnapHelper().attachToRecyclerView(this)
}

(2) ViewPager


2.1、什么是ViewPager


布局管理器允许左右翻转带数据的页面,你想要显示的视图可以通过实现PagerAdapter来显示。这个类其实是在早期设计和开发的,它的API在后面的更新之中可能会被改变,当它们在新版本之中编译的时候可能还会改变源码。
ViewPager经常用来连接Fragment,它很方便管理每个页面的生命周期,使用ViewPager管理Fragment是标准的适配器实现。最常用的实现一般有FragmentPagerAdapter和FragmentStatePagerAdapter。
ViewPager是android扩展包v4包中的类,这个类可以让我们左右切换当前的view。我们先来聊聊ViewPager的几个相关知识点:
1、ViewPager类直接继承了ViewGr0up类,因此它一个容器类,可以添加其他的view类


2、ViewPager类需要一个PagerAdapter适配器类给它提供数据(这点跟ListView一样需要数据适配器Adater)


3、ViewPager经常和Fragment一起使用,并且官方还提供了专门的FragmentPagerAdapterFragmentStatePagerAdapter类供Fragment中的ViewPager使用


2.4 移动+淡入动画:补间动画


<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="0.0"
android:toAlpha="1.0"
android:duration="500"/>

<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:duration="500"/>

2.5 自定义view


1 .创建一个类继承于View


class DrawView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
}

2 .重写构造方法


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}

2.6 播放Lottie资源


1.在Lottie寻找合适的资源放在资源项目下


VAC00JD74ROG)Q1CE0LMR.png


2. 在布局里使用


<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottieAnimationView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.304"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_rawRes="@raw/anim4" />

作者:candy_
来源:juejin.cn/post/7305984583984234534
收起阅读 »

哭了,朋友当韭菜被割惨了

最近我的朋友,被某些知识付费坑得很惨。全程毫无干货可言。内容仅仅只适用于初级、或者说部分中级的程序员。为此,我的朋友交了大几千的学费,却收获甚微。 当然,你可能说,是你的朋友问题啊?你朋友烂泥扶不上墙,学习方法不对,别人都有很多成功的案例。什么offer收到...
继续阅读 »

最近我的朋友,被某些知识付费坑得很惨。全程毫无干货可言。内容仅仅只适用于初级、或者说部分中级的程序员。为此,我的朋友交了大几千的学费,却收获甚微。



当然,你可能说,是你的朋友问题啊?你朋友烂泥扶不上墙,学习方法不对,别人都有很多成功的案例。什么offer收到手酸,外包入大厂。




我买这些课就是为了学习,入门一些语言。知识付费很合理呀!!



于是我跟我朋友在微信彻夜长谈,有了如下分析


先说结论



请擦亮你的慧眼,你的一分一毫来之不易。不到迫不得已,才当学费



为什么这么说?


首先,不管你是想就业,还是想学习一些新的技术,网上都有例子,github上也会有前沿的项目提供学习。


类型结论
学习新技术某项技术开源出来,作为技术的布道者,恨不得你免费过去学习,然后你再发一篇文章,越来越多人学习你的技术。
就业简历包装无非就是抄抄抄,抄别人的优秀代码。github开源项目就非常合适

其次,你学费,一定要做到利益最大化。必须要有以下两点



  • 能学到大部分人都学不到的技术亮点。记住,是大部分人,一定要做到差异化

  • 能学到优秀的学习方法,push你前进。


开启慧眼


现在市面的学习机构,鱼龙混杂。,B站大学,某识xin球,某ke时jian 甚至,在某音上,都有那种连麦做模拟面试,然后引导你付费学习。


就业环境不好,买方市场竞争激烈,某些人就抓住你的焦虑心理,坑你一把。回想你的求学生涯,是否也有类似被坑经历?醒醒吧,少年。能救你的,只有你自己


当然,小海也会有潜龙。不可否认,知识付费为我们提供了便利性。



  • 原本散乱无章的知识点,人家给你整理好了,你尽管就是学习,实践

  • 面对焦虑,你觉得很迷茫,需要一个人指点你前进

  • 能认识更多同样诉求的人,为以后学习,就业,甚至做生意提供可能


但是,某些不法分子,就是抓住你的这个心理,疯狂ge你韭菜。什么10块钱知识手册,19.9面试题,100块钱的项目视频。天天一大早,就转发一些公众号到你群上,dddd。


这些内容,不是说没有用。我们讨论适合人群,这类东西不适合中高级程序员



说那么多,你得学会判断这个人是不是大佬




你都可以简历包装,为什么‘大佬’就不会是被包装的



那就稍微整理一下,哪些是真大佬,伪大佬


真伪大佬


某佬博客开源项目学习人群是否顺眼
伪大佬面试题居多,很多基础内容,没有干货无,或者很少。动不动就是商城,博客应届生占比较多可能顺眼
真大佬博客、论坛内容干货。整理分类完善,你能学到东西有,某些大项目的贡献,同时也有优秀开源项目应届生,中高级都有大多数不顺眼,因为实在优秀

就学习人群做一个说明



  • 在就业容易程度上,相对于初中高级别的程序员,应届生无论从考察的内容,招聘的人数。都会容易丢丢。

  • 他说跟着他学,offer赢麻了。但是其中,找到工作的大多数都是应届生


就这些点,我们其实可以能判断个大概了。


记住,你想知识付费。一定要摸清他的底细,不能认为他说得都是对的。人家也是会包装的


你的hello world


或许每个程序员的第一行代码,都是


    print("hello world")

我想说的是,请你记住你的初心。



  • 转行过来当程序员,就是为了狠狠赚他一笔

  • 喜欢写代码,苦中作乐


情况每个人都不太一样,这里不细说。明白你是谁,你还是否有动力能坚持下去。明白这一点,远比你在迷茫的时候病急乱投医更为重要,请勿过度焦虑


为此,后面会说一下如何学习,以及找工作如何不被骗


力量大会


事关钱包的问题,我们都得谨慎谨慎。就业市场那恶劣,朋友找不到工作还被坑了一把。骗子实在可恶。请你先自身强大,先自己找出问题,不花冤枉钱,避免传销式编程


如有雷同,纯属巧合,没有针对任何人,也没有动某些人的饭碗。


作者:Goland猫
来源:juejin.cn/post/7357231056288055336
收起阅读 »

面试官:假如有几十个请求,如何去控制并发?

web
面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的? 让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!! 我:利用Promise模拟任务队列,从而实现...
继续阅读 »

面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的?

让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!!

我:利用Promise模拟任务队列,从而实现请求池效果。

面试官:大佬!


废话不多说,正文开始:


众所周知,浏览器发起的请求最大并发数量一般都是6~8个,这是因为浏览器会限制同一域名下的并发请求数量,以避免对服务器造成过大的压力。


首先让我们来模拟大量请求的场景


const ids = new Array(100).fill('')

console.time()
for (let i = 0; i < ids.length; i++) {
console.log(i)
}
console.timeEnd()

image.png


一次性并发上百个请求,要是配置低一点,又或者带宽不够的服务器,直接宕机都有可能,所以我们前端这边是需要控制的并发数量去为服务器排忧解难。


什么是队列?


先进先出就是队列,push一个的同时就会有一个被shift。我们看下面的动图可能就会更加的理解:


e0a2696a2299a3692d030dc7b956089a.gif


我们接下来的操作就是要模拟上图的队列行为。


定义请求池主函数函数


export const handQueue = (  
reqs // 请求数量
) => {}

接受一个参数reqs,它是一个数组,包含需要发送的请求。函数的主要目的是对这些请求进行队列管理,确保并发请求的数量不会超过设定的上限。


定义dequeue函数


const dequeue = () => {  
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}

这个函数用于从请求池中取出请求并发送。它在一个循环中运行,直到当前并发请求数current达到最大并发数concurrency或请求池queue为空。对于每个出队的请求,它首先增加current的值,然后调用请求函数requestPromiseFactory来发送请求。当请求完成(无论成功还是失败)后,它会减少current的值并再次调用dequeue,以便处理下一个请求。


定义返回请求入队函数


return (requestPromiseFactory) => {  
queue.push(requestPromiseFactory) // 入队
dequeue()
}

函数返回一个函数,这个函数接受一个参数requestPromiseFactory,表示一个返回Promise的请求工厂函数。这个返回的函数将请求工厂函数加入请求池queue,并调用dequeue来尝试发送新的请求,当然也可以自定义axios,利用Promise.all统一处理返回后的结果。


实验


const enqueue = requestQueue(6) // 设置最大并发数
for (let i = 0; i < reqs.length; i++) { // 请求
enqueue(() => axios.get('/api/test' + i))
}

动画.gif


我们可以看到如上图所示,请求数确实被控制了,只有有请求响应成功的同时才会有新的请求进来,极大的降低里服务器的压力,后端的同学都只能喊6


整合代码


import axios from 'axios'

export const handQueue = (
reqs // 请求总数
) => {
reqs = reqs || []


const requestQueue = (concurrency) => {
concurrency = concurrency || 6 // 最大并发数
const queue = [] // 请求池
let current = 0

const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}

}

return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}

}

const enqueue = requestQueue(6)

for (let i = 0; i < reqs.length; i++) {

enqueue(() => axios.get('/api/test' + i))
}
}

作者:大码猴
来源:juejin.cn/post/7356534347509645375
收起阅读 »

别忘了前端是靠什么起家的😡😡😡

web
一、忘了最基础的东西 前端开发的核心构建在三大基石技术上:HTML、CSS和JavaScript。回想起多年前,前端开发者常被戏称为“切图仔”,但就是这样的角色,通过精湛的CSS技巧,能够实现各种复杂的交互和特效,展现出前所未有的网页魔法。这是那些专注于服务端...
继续阅读 »

一、忘了最基础的东西


前端开发的核心构建在三大基石技术上:HTML、CSS和JavaScript。回想起多年前,前端开发者常被戏称为“切图仔”,但就是这样的角色,通过精湛的CSS技巧,能够实现各种复杂的交互和特效,展现出前所未有的网页魔法。这是那些专注于服务端开发的工程师所难以企及的领域。因此,前端工程师这一职业逐渐崭露头角,早期的培训班甚至设立了专门的课程来传授这些技能。然而,随着时间的推移,UI组件库和框架变得越来越普及,HTML和JavaScript的重要性依旧被人们所认可,但CSS技能却逐渐被边缘化,甚至有所忽视。在一次代码走查中,发现一个拥有三四年前端开发经验的同事,连CSS最基本的类型选择器都掌握不熟练。这一现象令人感到忧虑。


二、令人无语的代码


在一次对 useState 的使用场景进行治理的过程中。发现了一段感觉很无语的代码。代码我简化一下如下所示:


import React, { useState } from 'react';
import { Input } from 'antd';
import type { FC } from 'react';
import styles from './index.less';

const Test: FC = () => {
const [isFocus, setIsFocus] = useState(false);

return (
<Input
className={isFocus ? styles['input-focus'] : styles.input}
onFocus={() =>
{
setIsFocus(true);
}}
onBlur={() => {
setIsFocus(false);
}}
/>

);
};

export default Test;

.input-focus{
background: #f2f3f;
}

三、询问缘由


这段代码的目的是根据输入框的焦点状态(聚焦或失去焦点)来改变其样式,逻辑上没有问题。


我找到编写这段代码的同事询问:“为什么需要定义一个isFocus状态呢?”


他看了代码良久,有些疑惑地解释说:“这是为了追踪输入框的聚焦状态,从而在聚焦时改变背景色。”


“这个状态还有其他用途吗?”我追问。


“没有,就这个作用。有问题吗?”他回答。


我继续探询:“不使用isFocus状态,我们还能达到同样的效果吗?”


他思考了一会儿:“如果不添加类名来标识输入框的聚焦状态,我们怎么区分呢?”


我提出了另一种方案:“我们能不能仅用CSS来实现这个效果?”


他迟疑了一下:“但是CSS怎么能识别输入框是否聚焦呢?”


我提醒他:“你有没有试过使用伪类选择器?”


“伪类?我通常只用类选择器。”他回答。


我解释道:“我们可以使用:focus伪类来实现这个效果。你可以先回去继续你的工作。”


四、审查他另外的代码


我继续审查了这位同事的其他代码,发现他对CSS的理解似乎并不深入。例如,为了实现列表的斑马纹效果,理应直接使用:nth-child(odd):nth-child(even)选择器,但他却通过在遍历过程中判断索引是奇数还是偶数来分别添加不同的类选择器实现这一效果。此外,他同时使用了float: leftposition: absolute,这在布局中是矛盾的组合。他还通过JavaScript动态添加类选择器来改变输入框提示文字的字体颜色,还一直重复定义colorfont-size而不懂这些可以继承。


我不确定这是否反映了他的态度问题或是能力问题,在现在只出不进,内部消化的环境下,我默默地记录下这些,以便将来作为评估的参考。


五、关键是理解而不是记忆


也许会有人觉得我要求的太苛刻,也许这位同事只是忘记了有这几个CSS选择器。的确,CSS选择器的种类众多,达到60多种,可能会让人难以记住每一个。然而,重点并不在于能否一一背诵每个选择器,而在于理解它们各自的功能和使用场景。这样,当面对特定的样式需求时,我们可以轻松地查找并应用最合适的选择器来实现目标效果。


最基本的元素选择器、类选择器、和ID选择器因其简洁直观而被频繁使用。但是,深入探索那些不那么显眼的选择器——如通配符选择器、组合选择器、属性选择器、伪类选择器、和伪元素选择器——同样至关重要。这些选择器赋予了我们更精细的控制权,使得我们能够创造出更加复杂和细腻的视觉效果。


总之,我们不必强迫自己记住所有CSS选择器。更为重要的是认识到CSS选择器的多样性和强大之处。这种认识使我们能够在遇到具体的样式挑战时,知道如何寻找解决方案,从而更高效地运用CSS优化我们的代码。


为了真正理解这些选择器,我们需要思考它们被设计出来的原因——它们是如何帮助我们更好地控制样式,应对各种布局和视觉挑战的。这种深入的理解方式,远比简单的记忆更为重要和有效。


六、为啥需要伪类选择器


伪类选择器在CSS中的存在有着重要的意义和作用。它们提供了一种方式来选择HTML文档中无法通过简单选择器(如元素选择器、类选择器或ID选择器)直接选择的元素。伪类选择器的设计初衷和主要用途包括以下几点:


1、表达元素的特定状态


伪类选择器允许开发者根据用户与页面的交互来改变元素的样式,而不需要改变HTML代码。例如,:hover伪类可以用来改变鼠标悬停在链接或按钮上时的样式,:focus伪类用于当元素获得焦点时(比如输入框被点击时),而:active伪类则用于元素被激活(通常是被点击)的瞬间。这些都是基于用户行为的动态变化,通过CSS直接实现,无需JavaScript介入,提高了网页的交互性和用户体验。


2、选择特定位置的元素


伪类选择器还可以用来选择处于特定位置的元素,例如第一个子元素、最后一个子元素或者是父元素的唯一子元素。这对于设计复杂的布局和样式非常有用,尤其是在处理列表、表格和导航菜单时。例如,:first-child:last-child:nth-child()等伪类选择器,它们提供了一种灵活的方式来选择和样式化这些特定位置的元素。


3、选择特定属性的元素


虽然属性选择器(如[attribute=value])可以用来基于元素的属性选择元素,但某些伪类选择器(如:checked)提供了更为简便的方式来选择具有特定属性的元素。例如,:checked伪类选择器可以选择所有选中的复选框和单选按钮,这对于创建自定义表单控件的样式非常有用。


4、增强可访问性


伪类选择器还可以增强网页的可访问性。例如,:focus伪类可以用来为获得焦点的元素定义明显的样式,这对于键盘导航用户来说非常重要。通过提供视觉反馈,用户可以更容易地识别当前交互的元素,从而提高网站的可访问性。


5、无需额外的HTML标记


使用伪类选择器,开发者可以在不增加额外HTML标记的情况下,实现复杂的样式和布局。这有助于保持HTML代码的简洁和语义化,同时还可以减少页面的大小和提高加载速度。


总之,伪类选择器为CSS提供了强大的功能,使得开发者能够以更细致和动态的方式控制网页的样式。它们是现代网页设计中不可或缺的工具,使得网页能够响应用户的交互,同时保持代码的整洁和高效。


七、为啥需要伪元素选择器


伪元素选择器在CSS中的引入,为网页设计和内容表现提供了更加丰富和灵活的手段。伪元素选择器允许开发者访问并样式化一个元素的特定部分,或者在文档树中虚拟地创建新的元素,而这些通常不能通过HTML直接实现。伪元素选择器的存在有几个重要的原因和用途:


1、访问和样式化文档的特定部分


伪元素选择器使得开发者能够访问并样式化元素的特定部分,比如第一行文本、第一个字母、或者元素之前和之后的内容。例如,::first-line::first-letter 伪元素分别允许开发者为元素的第一行文本和第一个字母设置特定的样式。这在打造具有吸引力的排版和阅读体验时非常有用。


2、在不改变HTML结构的情况下添加内容


通过使用 ::before::after 伪元素,开发者可以在元素的内容之前或之后插入新的内容或装饰,而不需要修改HTML代码。这种方法非常适合添加图标、装饰性元素或者是为元素添加特殊的前缀或后缀,同时保持HTML的清晰和语义化。


3、创建视觉效果


伪元素选择器也常被用于创建特殊的视觉效果,比如自定义的清除浮动方法(使用 ::after 清除浮动),或者是设计复杂的背景装饰和形状。这些都可以通过伪元素以及结合CSS的其他特性(如backgroundborderbox-shadow等)来实现。


4、提高网页性能


使用伪元素可以在不增加额外HTML元素的情况下实现复杂的设计,这有助于减少DOM的大小,从而提高网页的性能。通过减少页面加载时需要解析的HTML标签数量,可以加快页面的渲染速度。


5、保持HTML的语义化


通过使用伪元素来添加装饰性内容或样式,开发者可以避免在HTML中添加非语义化的标记。这有助于保持HTML文档的清晰和语义化,使得文档的结构更加明确,也更容易被搜索引擎优化(SEO)和屏幕阅读器理解。


总之,伪元素选择器为CSS提供了强大的功能,使得开发者能够以更细致和动态的方式控制网页的样式和内容。它们是现代网页设计中不可或缺的工具,允许开发者在不牺牲HTML语义化的前提下,实现复杂和创新的设计。


八、为啥需要属性选择器


属性选择器在CSS中的引入提供了一种强大的方式来根据元素的属性及其值来选择元素,从而应用特定的样式。这种选择器的存在和使用有几个关键的原因和优势:


1、精确选择和样式化元素


在复杂的网页设计中,开发者可能需要对具有特定属性或属性值的元素应用样式,而不是仅基于元素类型、类或ID。属性选择器使得这种精确选择成为可能。例如,可以选择所有设置了target="_blank"属性的<a>标签,并为它们应用特定的样式,以提示用户这些链接将在新窗口中打开。


2、提高CSS规则的灵活性


属性选择器增加了CSS规则的灵活性,允许开发者基于元素的属性和属性值来创建复杂的选择条件。这意味着开发者可以在不修改HTML结构的情况下,通过CSS实现更多的设计需求和响应式布局。


3、增强样式的可维护性


使用属性选择器,开发者可以避免在HTML中过度使用类或ID,从而简化HTML结构并提高样式的可维护性。当需要基于相同属性的元素应用统一的样式时,只需在CSS中定义一次相应的属性选择器规则,而不是在HTML中为每个元素重复添加类或ID。


4、促进更好的语义化和可访问性


属性选择器可以用来增强文档的语义化和可访问性。例如,通过选择具有特定role属性的元素并为它们应用样式,开发者可以帮助提高网页对于屏幕阅读器等辅助技术的可访问性。


5、实现条件样式


在某些情况下,开发者可能希望仅在元素具有特定属性或属性值时才应用样式。属性选择器使得这种条件样式化成为可能,无需额外的类或ID,也无需使用JavaScript。这种方式非常适合实现基于特定数据属性(data-*属性)的样式变化。


示例


假设我们想为所有含有特定属性data-tooltip的元素添加一个工具提示样式,我们可以使用如下CSS规则:


[data-tooltip] {
position: relative;
cursor: pointer;
}

[data-tooltip]:before {
content: attr(data-tooltip);
/* 更多的样式规则来定义工具提示的外观 */
}

这个示例展示了如何仅通过CSS和HTML属性来实现一个简单的工具提示功能,无需修改HTML结构或使用JavaScript。


总之,属性选择器为CSS提供了更多的选择和样式化能力,增加了样式表的灵活性和可维护性,同时促进了更好的文档结构和语义化。


九、为啥需要组合选择器


组合选择器在CSS中扮演着至关重要的角色,它们提供了一种强大的机制来选择具有特定关系的元素,从而允许开发者以更精细、更具体的方式应用样式。组合选择器的存在和使用主要基于以下几个原因:


1. 提高选择器的精确性


在复杂的网页布局中,仅使用简单选择器(如元素选择器、类选择器或ID选择器)往往难以精确地定位到特定的元素。组合选择器通过定义元素之间的关系(如父子关系、相邻关系等),使得开发者可以更精确地选择到目标元素。这种精确性对于实现特定的布局和样式效果至关重要。


2. 优化CSS的结构


使用组合选择器,可以避免在HTML中过度使用类或ID来达到样式目的,从而使得CSS的结构更加清晰和简洁。这种方法有助于提高代码的可维护性和可读性,同时减少了因重复定义样式而导致的冗余。


3. 实现更复杂的样式设计


组合选择器提供了一种方式来实现基于特定元素关系的复杂样式设计。例如,开发者可以使用子选择器(>)来仅为特定父元素的直接子元素应用样式,或使用相邻兄弟选择器(+)来为紧跟在特定元素后的兄弟元素应用样式。这种灵活性使得开发者能够创造出更加动态和富有层次感的页面布局和视觉效果。


4. 提升样式的可复用性


通过使用组合选择器,开发者可以为特定的元素关系定义样式,而不是针对特定的类或ID。这种做法增加了样式的可复用性,因为相同的组合选择器样式可以在不同的HTML结构中被复用,只要这些结构符合选择器定义的元素关系。


5. 保持HTML的语义化


组合选择器的使用有助于保持HTML代码的语义化,因为它们允许开发者基于元素之间的自然关系来应用样式,而不是强迫添加额外的类或ID。这样不仅使得HTML结构更加清晰,也有助于搜索引擎优化(SEO)和提高网站的可访问性。


示例


假设我们想为一个列表中的第一个项目添加特殊样式,我们可以使用子选择器和伪类选择器的组合来实现这一点:


ul > li:first-child {
color: red;
}

这个示例展示了如何使用组合选择器来精确选择并样式化特定的元素,而无需为该元素添加额外的类或ID。


总之,组合选择器是CSS中不可或缺的一部分,它们通过定义元素之间的关系增强了选择器的功能,使得开发者能够以更灵活、更高效的方式设计和实现网页样式。


作者:前端大骆
来源:juejin.cn/post/7357194991339143168
收起阅读 »

前端部署发布项目后,如何解决缓存的老版本文件问题

web
针对这个问题有两个思路 方式一:纯前端 每次打包发版时都使用webpack构建一个version.json文件,文件里的内容是一个随机的字符串(我用的是时间戳),每次打包都会自动更新这个文件。 项目中,通过监听点击事件来请求version.json文件。使用本...
继续阅读 »

针对这个问题有两个思路


方式一:纯前端


每次打包发版时都使用webpack构建一个version.json文件,文件里的内容是一个随机的字符串(我用的是时间戳),每次打包都会自动更新这个文件。


项目中,通过监听点击事件来请求version.json文件。使用本地缓存将上一次生成的字符串存储起来,和本次请求过来的字符串进行对比;若字符串不一样,则说明有项目有新内容更新,提供用户刷新或清除缓存(我使用的)


方式二:前后端配合


在每个请求头加上发版的版本号,和保留在客户端的上一次版本号进行对比,如果不一致则强制刷新,刷新后保存当前版本号


实现:


1、webpack构建生成一个json文件,在项目目录下新建一个plugins的文件夹,新建version-webpack-plugin.js文件


webpack4****等高版本构建方式


/** Customized plug-in: Generate version number json file */const fs = require("fs");class VersionPlugin {  apply(compiler) {    // emit is an asynchronous hook, use tapAsync to touch it, you can also use tapPromise/tap (synchronous)    compiler.hooks.emit.tap("Version Plugin", (compilation) => {      const outputPath = compiler.path || compilation.options.output.path;      const versionFile = outputPath + "/version.json";      const timestamp = Date.now(); // timestamp as version number      const content = `{"version": "${timestamp}"}`;      /** Returns true if the path exists, false otherwise */      if (!fs.existsSync(outputPath)) {        // Create directories synchronously. Returns undefined or the path to the first directory created if recursive is true. This is the synchronous version of fs.mkdir().        fs.mkdirSync(outputPath, { recursive: true });      }      // Generate json file      fs.writeFileSync(versionFile, content, {        encoding: "utf8",        flag: "w",      });    });  }}module.exports = { VersionPlugin };

webpack3


低版本构建方式


/** Customized plug-in: Generate version number json file */const fs = require('fs')class VersionPlugin {  apply(compiler) {    compiler.plugin('done', function () {      // Copy the logic of the file, and the file has been compiled.      const outputPath = compiler.outputPath      const versionFile = outputPath + '/version.json'      const timestamp = Date.now() // 时间戳作为版本号      const content = `{"version": "${timestamp}"}`      /** Returns true if the path exists, false otherwise. */      if (!fs.existsSync(outputPath)) {        // Create directories synchronously. Returns undefined or the path to the first directory created if recursive is true. This is the synchronous version of fs.mkdir().        fs.mkdirSync(outputPath, { recursive: true })      }      // Generate json file      fs.writeFileSync(versionFile, content, {        encoding: 'utf8',        flag: 'w'      })    })  }}module.exports = { VersionPlugin }

2、在vue.config.js中使用这个plugin


const { VersionPlugin } = require('./src/plugin/version-webpack-plugin')

config.plugins.push(new VersionPlugin())


3、在每次执行webpack构建命令,都会在dist目录下生成一个version.json文件,里面有一个字段叫version,值是构建时的时间戳,每次构建都会生成一个新的时间戳。




4、发起ajax请求,请求version.json文件获取version时间戳,和本地保存的上一次的时间戳做比较,如果不一样,则进行对应的操作。/business/version.json,business是我项目的前缀,改成你自己的项目地址,能请求到version.json文件就行。


import axios from 'axios'import i18n from '@/i18n'import UpdateMessage from '@/components/common/UpdateProject/index.js'export function reloadVersion() {  axios.get(window.location.origin + '/mobile/version.json?v=' + Date.now()).then(rsp => {    let mobileVersion = localStorage.getItem('mobileVersion')    let onlineVersion = rsp.data.version    if (!mobileVersion) {      localStorage.setItem('mobileVersion', onlineVersion)      return    }    if (onlineVersion) {      if (mobileVersion !== onlineVersion) {        UpdateMessage.success({          title: i18n.t('bulk.pleaseWait'),          msg: i18n.t('common.updateRemind')        })        setTimeout(() => {          UpdateMessage.close()          localStorage.setItem('mobileVersion', onlineVersion)          window.location.reload();        }, 2000);      }    }  })}

5、请求发起的时机,可以使用定时器或者在切换页面的时候进行校验版本。根据自己的实际情况选择合适的调用时机。


async mounted() {  process.env.NODE_ENV !== 'development' && window.addEventListener('mousedown', this.handleonmousedown);},beforeDestroy() {  window.removeEventListener('mousedown', this.handleonmousedown)},

handleonmousedown() { reloadVersion()}

作者:jskai
来源:juejin.cn/post/7356049143955390518
收起阅读 »

RecyclerView+多ItemType实现两级评论页面

多ItemType实现多级评论页面 前言 我的上一篇文章 Android简单的两级评论功能实现,得到了很多的评论(万万没想到),收获了jym们宝贵的建议和指导,在此特别感谢大家。 在上一篇文章中,对于‘两级评论功能’的实现,我采用的是Recycler嵌套的方法...
继续阅读 »

多ItemType实现多级评论页面


前言


我的上一篇文章 Android简单的两级评论功能实现,得到了很多的评论(万万没想到),收获了jym们宝贵的建议和指导,在此特别感谢大家。


在上一篇文章中,对于‘两级评论功能’的实现,我采用的是Recycler嵌套的方法,这种方法的实现不难,但是非常的麻烦,有很多不必要的操作,扩展性很差,维护起来也是十分复杂。虽然最后实现效果还可以,但是有更好更方便的方法,何乐而不为呢。所以这篇文章的内容就是对多ItemType实现评论功能的过程阐述,还有两种实现方式的区别和性能差异。


:文章要参加更文活动,只会粘贴关键的代码。如需详细代码,请私信。


一、适配器


重复的部分就不说了,数据库和布局部分基本和上一篇是一致的,只是把item布局中的RecyclerView和对应的适配器及相关代码去掉了。


1、创建两个ViewHolder


分别是TestOneViewHolder和TestTwoViewHolder,这里不贴代码只展示布局了


一级评论的布局:


image.png


二级评论的布局:


image.png


2、设置两个ItemType


LEVEL_ONE_VIEW一级评论的ViewType,LEVEL_TWO_VIEW二级评论的ViewType


private val LEVEL_ONE_VIEW = 0 // 一级布局的的ViewType
private val LEVEL_TWO_VIEW = 1 // 二级布局的的ViewType

3、方法重写



  • getItemViewType方法中返回ViewType


override fun getItemViewType(position: Int): Int {
val commentInfo = list.toList()[position].first
return if (commentInfo.level == 1) {
LEVEL_ONE_VIEW
} else {
LEVEL_TWO_VIEW
}
}


这里list的类型是‘Map<CommentInfo, User>’,是因为还需要User的数据,所以映射来的。
获取到评论信息后对level进行判断,返回相应的ViewType。




  • onCreateViewHolder中根据ViewType进行判断,根据TYPE返回相应的ViewHolder


override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == LEVEL_ONE_VIEW) {
TestOneViewHolder(parent)
} else {
TestTwoViewHolder(parent)
}
}


  • onBindViewHolder通过getItemViewType(position)来获取当前的ViewType,再进行数据绑定。如图:


image.png

4、数据绑定


在ViewHolder中将传入的数据对布局进行赋值就好了,最后实现的效果如下图。为了能够更加直观的看出一级评论与二级评论之间的关联,图片中的评论内容用数字进行标识。


微信图片_2.jpg

可以看到在设置完多ItemType后,显示的布局符合我们的预期了,可是一级评论和二级评论之间毫无关联,各过各的,那如何将评论布局展示出绑定的效果呢?主要还是对数据进行处理啦,如何处理呢,请看下一节。



二、绑定


这个绑定指的是将与一级评论相关联的二级评论和该一级评论展示在一起,有一种类似的绑定效果。大致思路如下:



  1. 获取该文章的所有评论

  2. 分别获取到level为1、2的评论列表

  3. 将level为2的列表按照回复评论的Id进行分组

  4. 创建空列表

  5. 遍历level为1的列表,获取到相应的level2的列表并依次添加进空列表


实现代码如下:


// 获取该文章的所有评论
val comments = commentStoreRepository.getCommentsByNewId(newsId)
// 获取level为1、2的评论、按时间进行排序
val level1 = comments.filter { it.level == 1 }.sortedBy { it.time }
val level2 = comments.filter { it.level == 2 }.sortedBy { it.time }
// 将level为2的列表按照回复评论的Id进行分组
val level2Gr0up = level2.groupBy { it.replyId }
// 创建空列表
val list = mutableListOf<CommentInfo>()
// 遍历level1的列表 获取到对应的level2列表 依次添加进空列表中
level1.forEach { level1Info ->
val newLevel2Gr0up = level2Gr0up[level1Info.id]
list.add(level1Info)
if (newLevel2Gr0up != null) {
list.addAll(newLevel2Gr0up)
}
}


这个空列表,即list就是我们需要的能展示强绑定关系的列表啦



最终呈现的效果如下图


微信图片_1.jpg

这样,一个多ItemType的二级评论展示就实现啦!!!


三、两种实现方式比对



  1. 实现1 - 嵌套RecyclerView的实现

  2. 实现2 - 多ItemView的实现


1、复杂程度:


主观方面来说,



  • 实现1 -- 首先是在数据及布局的处理方面,会显得非常杂乱。我在非常了解其数据结构的情况下,很多时候也摸不着头脑,而且代码不方便管理。再就是扩展性,如果在这个实现的基础上进行扩展会非常的复杂,想着要是做个更多层级的评论那得多麻烦。优点就是能够对二级评论进行单独的管理。

  • 实现2 -- 单独对布局进行管理,很方便,复杂程度低,扩展性也更好,用来做个多级评论不成问题。缺点:我想实现一个评论下的二级评论最多展示两条,可以展开,还可以显示回复条数的功能不知道怎么做,实现一因为可以对二级数据统一管理就会比较好实现。这一点,如果有大佬知道如何解决,请在评论下激情发表你的言论。


060c26572c4bf3f54107bd8b1d0e713.jpg


2、性能方面:


分别插入100100010000条数据,记录消耗时间。如图所示,统计的次数较少,但也可以看出二者在性能方面的差异不大。


结论两种实现性能差异较小。


image.png

四、结语


以上,就是多个ItemType实现二级评论的过程和结果以及两种实现方式的主观对比。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7273685263841853496
收起阅读 »

24清明节咋过的?

头一天在朋友家呆了一天,下午又去武汉最负盛名的公园溜达了一会。 晚上回家,打算出去骑车,一直没想到目的地,想去大山里,但是总感觉自己的行李不顺心,帐篷太长,也没有炊具,没有视频里up主们那种风餐露宿,悠然自得的感觉。 这估计就是典型的i人了。只要有一点不顺遂...
继续阅读 »

头一天在朋友家呆了一天,下午又去武汉最负盛名的公园溜达了一会。


东湖.jpg
晚上回家,打算出去骑车,一直没想到目的地,想去大山里,但是总感觉自己的行李不顺心,帐篷太长,也没有炊具,没有视频里up主们那种风餐露宿,悠然自得的感觉。


这估计就是典型的i人了。只要有一点不顺遂,就会变的犹犹豫豫,然后大概率放弃。


然后我就放弃了原本打算去大山里待两天的计划。当然,我还是朝着大山所在的方向出发了。


打开地图,切换到地形模式,就可以看到藏在平面图里的山川河流了,我发现了一条沿着长江边伸展出去的小路,于是把这条路当做了我的目的地。


应该的假期的缘故,早上出发的时候难得的没有遇到堵车,很多路我都没有开导航,全凭感觉在走。有时候看到有趣的小路我还会直接拐进去。


有一回我拐到了一个小山下面,上山的小路就在眼前,但是因为前几天下雨的缘故,路上还有积水和泥泞的车辙印,休闲骑不越野,遂放弃上山。


后来又经过一个小镇,就跟着前面一辆小电动车一起穿街走巷,最后到路的尽头,发现正是长江,而我也终于拐到了那条沿江的小路上。这条路同时是长江的防洪堤,路边每隔几百米就有一块牌子,提醒过路的人们防洪的重要性。


我沿着江堤一直走啊走,左边是缓慢流淌的长江,右边是无边无际的田野。往左边看,是孤帆远影碧空尽,往右边看又是莽莽沃野,风吹草低。


微信图片_20240409134731.jpg
骑了大约半个小时左右,感觉有点尿急了,可是在江堤上,找个厕所是不可能了,但是也不能随地就解决啊,路上时常还有车辆经过。


又骑了一会看到了一个破房子,就在路边不远处,掩在杂草丛中,心想这里算是比较隐蔽的了,下车走过去。可是越靠近那破房子就越觉得恐惧,脑子里开始蹦出来一些恐怖片的情景。其实我是不怕鬼怪的,咱是一个坚定的唯物主义者,当然我也不怕活人。但是我怕尸体,动物的就算了,这万一。


这还要从大学那会说起,学校后面有座小山,山上有几座破房子,那时候大家经常爬山去上面看日落日出,我也去过几次,对那个房子也算记忆深刻。后来有一天学校突然开始统计人数,一个一个的查,态度之庄重坚决,行动之迅速果断,前所未见。最后才得知原来有学生在后山的破房子里发现有人上吊了,这事给我留下了极大的阴影。以至于后来我都不敢去那座山上了。


言归正传,我还是壮着胆子靠近了这座破房子,这是一座典型的南方小楼,好像是两层,门洞很窄,门窗都没有了,一楼很干净,有一条狭窄的楼梯可以通到二楼。我也就到此为止了。当然我也不敢在这上厕所了。


又骑了一会,油灯开始闪烁了,上周加了50块钱的油,当时就跑了大约180公里,今天又跑了大约60公里,一合计50块钱的油跑了240公里,心里又快乐了,比之前50块钱跑200公里的时候还快乐。经过半年的磨合,我和车子终于要人车合一了。


打开地图搜索了一个最近的加油站,十几公里,又加了50块钱的油。冬天的时候一个月就加一次油,春天了基本两周就要加一次,这么一想我又不快乐了。


加完油已经到饭点了,果然人车合一了,要饿一起饿。在小镇上点了一份炒菜,一个人吃就点了一份鱼香肉丝,分量还挺足的,竹笋很脆嫩,现在是吃春笋的时节。


微信图片_20240409134748.jpg
吃完饭,又开始漫无目的的骑车,没有开导航,只是这次长江在我的左手边,因为小镇所在的位置是长江转弯处突出的一块洼地上,所以我以为我正在沿小镇绕圈,也可能是一路上的风景同质化太严重了,以至于我一直没发现正在走回头路。


然后就在我又一次尿急,开始搜寻合适的放水地的时候,我又看到了那座老房子。


作者:渡人先渡己
来源:juejin.cn/post/7355433339547664403
收起阅读 »

怎么下载加密ts流的视频

web
以某网站如下的电影《2012》为例。 在这个网站上面,电影2012是以一系列几秒的ts格式来播放的,所以没办法直接复制视频地址来下载整部电影。看如下截图: 并且,每段ts还是加密的,单独下载ts文件是无法播放的,需要解密,如下图: 那要怎样才能下载完整的解...
继续阅读 »

以某网站如下的电影《2012》为例。


在这个网站上面,电影2012是以一系列几秒的ts格式来播放的,所以没办法直接复制视频地址来下载整部电影。看如下截图:


image.png


并且,每段ts还是加密的,单独下载ts文件是无法播放的,需要解密,如下图:


image.png


那要怎样才能下载完整的解密后的视频呢?下面分几步进行说明。


1、首先,获取该电影所有的ts列表,和加密方式及密钥:


要用chrome浏览器打开该网址,然后右击,点击检查,然后重新刷新页面,然后根据如下截图查看:


image.png


点击“index.m3u8”这个请求,然后根据如下截图:
image.png


能够得出该电影的所有ts列表,并且加密方式是“AES-128”,密钥是enc.key的请求中,iv是16字节长度的0 。 现查看enc.key请求如下:
image.png


发现是乱码(有些网站不是乱码,而是字符串)。乱码是因为该密钥是二进制的,需要用查看hex工具来获取16进制的密钥。


先下载该“enc.key”到本地,然后用hex工具查看16进制值。mac系统可以用如下查看:


image.png


可以得出该密钥的16进制为:7be5d74d56af87838c3b98f1a2febf8f


2、根据ts列表,用php来实现多进程快速下载


下载所有ts文件有很多方法,可以手动一个个下载,但是因为太多,所以这个方法会比较麻烦。可以用php脚本来快速下载。


创建个1.php文件,用来下载ts文件。写入如下内容:


<?php

function my_file_get_contents($url) {
$arrContextOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
];
return file_get_contents($url, false, stream_context_create($arrContextOptions));
}

for ($i=$argv[1]; $i <= $argv[2]; $i++) {
echo $i.'...'.PHP_EOL;
$f = $i.'.ts';
if (file_exists($f)) {
continue;
}
// 下面的链接要改成“index.m3u8”这里面相对应的ts链接
$data = my_file_get_contents('https://hnts.ymuuy.com:65/hls/200/20240110/2077/plist'.$i.'.ts');
file_put_contents($f, $data);
}

然后,再创建个2.php文件,用来创建下载命令。写入如下内容:


<?php

// 882要改成改成“index.m3u8”这里面最大数字的ts链接后的数字
for ($i=1; $i<=882; $i+=20) {
$tmp = $i+20;
if ($tmp > 882) {
$tmp = 882;
}
echo 'php 1.php '.$i.' '.$tmp.' &'.PHP_EOL;
}

然后,运行如下命令:
image.png


生成了可以多进程下载ts文件的命令行,然后复制生成的命令,在终端运行如下:
image.png


可以看到,已经在快速下载了,分为了882/20=44个进程来同时快速下载。


可以用如下命令来查看下载进度:


while true
do
du -sh `pwd`; ls |wc -l;sleep 1;
done

显示如下:


image.png


会显示出当前下载的大小,和下载的总ts数。


注意,全部都下载完后,要查看下有没有大小为0的ts文件,这些是下载失败的文件,删除后,重新运行下下载命令即可。


3、所有文件都下载完后,要开始解密并合并了


同样也是用php脚本来解密,保存下面脚本为decrypt.php:


<?php

// 如果“enc.key”的密钥是二进制的话,就用下面这行
$key = hex2bin("7be5d74d56af87838c3b98f1a2febf8f");
// 如果“enc.key”的密钥是字符串的话,就用下面这行
// $key = 'Cibz2Dp3bCnzlmVx';

// 原样复制“index.m3u8”里面的IV的0x后面的部分
$iv = hex2bin("00000000000000000000000000000000");

$decrypted_file = 'output.ts'; // 最终要保存的文件

// 882改为ts总数
for ($i=1; $i<=882; $i++) {
echo $i,'...',PHP_EOL;
$encrypted_file = $i.'.ts';
$data = file_get_contents($encrypted_file);
$decrypted_data = openssl_decrypt($data, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
file_put_contents($decrypted_file, $decrypted_data, FILE_APPEND);
}

echo "解密成功,已保存为:".$decrypted_file;


运行如下命令:


image.png
image.png


这样,就成功的解密并合并为了output.ts文件,用支持ts的播放器就可以播放此电影了。


有问题这边留言探讨下~


作者:leptune
来源:juejin.cn/post/7356143704699519003
收起阅读 »

JAVA 一个简单查重的实现

JAVA 一个简单查重的实现 1. 前言 最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。...
继续阅读 »

JAVA 一个简单查重的实现


1. 前言


最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。于是只能亲自动手做一个 simple 版本的。


2. 实现思路


思路的话比较简单,想法是利用双指针的模式,找出两个文本中相似的文本。不过这样算法复杂度是 O(n2) ,不过由于我们是小网站,文章本来也不多。


image.png
image.png


核心就是有一个双层的循环做遍历,然后判断最小字符串是否相同,如果不相同,则指针B递增直到找到相同的文本


image.png


如果指针位置找到了相同文本,则增加最小字符串长度,直到找到最大的匹配的文本。


3. 代码实现



public static class SameResult {
// 存储相似文本关键词
private String keyword;
// 存储与关键词详细信息
private String detail;

public String getKeyword() {
return keyword;
}

public void setKeyword(String keyword) {
this.keyword = keyword;
}

public String getDetail() {
return detail;
}

public void setDetail(String detail) {
this.detail = detail;
}
}

/**
* 获取两个字符串中的相似文本片段
* @param a 文本a
* @param b 文本b
* @param minSize 最小相似字符数
* @return 返回相似文本片段的列表
*/

public static List<SameResult> getSameTextList(String a, String b, Integer minSize) {

List<SameResult> result = new ArrayList<>();
Map<String, String> stash = new HashMap<>();
if (a == null || b == null) {
return result;
}
if (a.length() < minSize || b.length() < minSize) {
return result;
}
int i = 0;
while (i <= a.length() - minSize) {
// 初始化窗口大小为最小相似字符数
int nowWindowSize = minSize;
// 遍历文本b,寻找与文本a当前片段相似的片段
int j = 0;
String nowMate = null; // 存储当前相似片段
String nowDetail = null; // 存储当前相似片段的详细信息
SameResult sameResult = new SameResult();
Boolean isMate = false; // 标记是否找到相似片段
while (j <= b.length() - minSize) {
// 如果文本a和文本b的当前片段相等
if (a.substring(i, nowWindowSize + i).equals(b.substring(j, nowWindowSize + j))) {
// 记录相似片段
nowMate = a.substring(i, nowWindowSize + i);
// 记录详细信息, 这里的5表示详细信息取前五个和后五个字符
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
// 设置找到相似片段的标记
isMate = true;
// 增加窗口大小
nowWindowSize++;
// 继续在文本b中寻找更长的相似片段
while (j <= b.length() - nowWindowSize) {
String ma1 = a.substring(i, nowWindowSize + i);
String ma2 = b.substring(j, nowWindowSize + j);
// 如果找到更长的相似片段
if (ma1.equals(ma2)) {
nowMate = a.substring(i, nowWindowSize + i);
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
nowWindowSize++;
} else {
// 如果不再相似,退出循环
break;
}
}
// 找到相似片段后,退出内部循环
break;
} else {
// 如果不相似,继续在文本b中寻找
j++;
}
}
// 如果找到相似片段,将其存储到映射中
if (isMate) {
// 移动文本a的索引
i += nowWindowSize - 1;
stash.put(sameResult.getKeyword(), sameResult.getDetail());
} else {
// 如果没有找到相似片段,移动文本a的索引
i++;
}
}
for (String key : stash.keySet()) {
SameResult sameResult = new SameResult();
sameResult.setKeyword(key);
sameResult.setDetail(stash.get(key));
result.add(sameResult);
}
return result;
}

public static void main(String[] args) {
// 调用getSameTextList方法,并打印结果
System.out.println(getSameTextList("test1", "test2", 10));
}

代码总体比较简单,就是获取到所有最小长度文本长度的所有相似文本,并放到一个 List 中,以便后续的业务处理。


最后可以整理为一个类似下面的表格


原文相似内容
脸哭声更为响亮。我问他是谁的悲他把他脸哭声更为响亮。我问他是谁使的打成这
情往往只是作为情的友爱和险情往往只是作为情可来及,正
着茂盛树叶的树下节了一棵已着茂盛树叶的树下,走的女棉花
再说我爹年轻时也我端人的子。再说我爹年轻时也好些一手,

4. 结尾


一般会用到查重的业务场景可能并不多,大部分都是学校、政府等才需要进行查重,本文算是抛砖引玉吧,只是为需要做查重内容展示时为大家提供一点点思路。


作者:码头的薯条
来源:juejin.cn/post/7355347789677035571
收起阅读 »

Android 图片裁剪

前言   图片裁剪是对图片进行区域选定,然后裁剪选定的区域,形成一个图片,然后再对这个图片进行压缩,最终返回结果图片。运行效果图 正文   从上面的描述来看貌似是挺简单的是吧,不过实际操作起来就没有那么简单了,下面先来看看简单的实现方式,就是Android自...
继续阅读 »

前言


  图片裁剪是对图片进行区域选定,然后裁剪选定的区域,形成一个图片,然后再对这个图片进行压缩,最终返回结果图片。运行效果图


在这里插入图片描述


正文


  从上面的描述来看貌似是挺简单的是吧,不过实际操作起来就没有那么简单了,下面先来看看简单的实现方式,就是Android自带的裁剪。


一、创建并配置项目


  我们依然从创建项目开始讲起,这虽然有一些繁琐,但无疑可以让每一个Android开发者看懂。创建一个名为PictureCroppingDemo的项目。


创建好之后,在app的build.gradle添加如下代码,有两处


	//JDK版本
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

	//  google权限管理框架
implementation 'pub.devrel:easypermissions:3.0.0'
//热门强大的图片加载器
implementation 'com.github.bumptech.glide:glide:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'

添加位置如下图所示:


在这里插入图片描述


然后打开AndroidManifest.xml,在里面添加两个权限


	<!--读写外部存储-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

这两个权限在Android6.0及以上版本属于危险权限,需要动态申请,下面来写权限申请的代码吧。


二、权限申请


  首先在MainActivity中重写这个onRequestPermissionsResult方法。这个方法属于Android原生的权限请求返回,下面来看它的具体内容:


	/**
* 权限请求结果
* @param requestCode 请求码
* @param permissions 请求权限
* @param grantResults 授权结果
*/

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// 将结果转发给 EasyPermissions
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this);
}

EasyPermissions就是刚才在build.gradle中添加的依赖库,然后写一个权限请求的方法。


	@AfterPermissionGranted(9527)
private void requestPermission(){
String[] param = {Manifest.permission.READ_EXTERNAL_STORAGE,Manifest.permission.WRITE_EXTERNAL_STORAGE};
if(EasyPermissions.hasPermissions(this,param)){
//已有权限
showMsg("已获得权限");
}else {
//无权限 则进行权限请求
EasyPermissions.requestPermissions(this,"请求权限",9527,param);
}
}

  这个requestPermission()方法上面有一个注解,这个注解是什么意思嗯呢,就是权限通过后再调用一次这个方法。然后看方法里面做了什么,定义了一个字符串数组,里面有两个权限,都是在AndroidManifest.xml中配置过的,实际上这两个权限在一个权限组里面,一个权限组只有有一个权限通过则表示整组权限通过,因此你只需要放置一个权限就好了,我这么写是为了让你更清楚一些。然后是一个判断,通过这框架去判断当前的权限是否以获取,是则进行后续操作,我这里是弹一个Toast,方法也很简单。


	/**
* Toast提示
* @param msg 内容
*/

private void showMsg(String msg){
Toast.makeText(this,msg,Toast.LENGTH_SHORT).show();
}

如果没有权限则通过下面这行代码去请求权限


EasyPermissions.requestPermissions(this,"请求权限",9527,param);

  这里的9527其实是一个请求码,它需要与注解中的对应,只有这样它在权限授予之后才会再次调用这个方法做检测。更规范的写法是定于一个全局变量,然后替换这个9527,比如这样


	/**
* 外部存储权限请求码
*/

public static final int REQUEST_EXTERNAL_STORAGE_CODE = 9527;

然后修改对应的地方即可,如下图所示:


在这里插入图片描述


最终记得在onCreate中调用这个requestPermission()方法。下面运行一下:


在这里插入图片描述


三、获取图片Uri


在上面我们已经获取到了权限,下面就来获取这个图片的Uri,然后通过图片Uri显示这个图片。


首先修改布局activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<ImageView
android:id="@+id/iv_picture"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="24dp"
android:onClick="openAlbum"
android:text="打开相册" />

</RelativeLayout>

  很简单的布局,这里唯一要说的就是这个onClick="openAlbum",如果你的按钮不需要进行设置的话,单个按钮的点击事件这样写更简洁一些,你会看到这个地方有一条红线,这需要到Activity中去写这个方法,你可以通过快捷键去生成这个方法。鼠标点击这个划红线的地方,然后Alt + Enter,下面会弹出一个窗口,第二项就是说在MainActivity中创建openAlbum方法。这种方式在Fragment中并不是适用,请注意。


在这里插入图片描述


然后你就会在MainActivity中看到这样的方法,请注意一点,这个方法名与你onClick中的值必须要一致。


	/**
* 打开相册
*/

public void openAlbum(View view) {

}

下面来写打开相册的方法。这里同样的需要一个请求码,去打开相册,然后通过返回的结果去读取图片的uri,定义一个请求码


	/**
* 打开相册请求码
*/

private static final int OPEN_ALBUM_CODE = 100;

然后在修改openAlbum方法,代码如下:


	/**
* 打开相册
*/

public void openAlbum(View view) {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_PICK);
intent.setType("image/*");
startActivityForResult(intent, OPEN_ALBUM_CODE);
}

注意这里使用了startActivityForResult,则需要获取返回值。重写onActivityResult方法。


	/**
* 返回Activity结果
*
* @param requestCode 请求码
* @param resultCode 结果码
* @param data 数据
*/

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);

}

这里先获取相册中的图片显示到Activity中,刚才在activity_main.xml中的ImageView控件就派上用场了。


	//图片
private ImageView ivPicture;

然后在onCreate中绑定xml的id。下面你再使用这个ivPicture就不会报空对象了。


	ivPicture = findViewById(R.id.iv_picture);

然后回到onActivityResult方法,修改代码如下:


	@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == OPEN_ALBUM_CODE && resultCode == RESULT_OK) {
final Uri imageUri = Objects.requireNonNull(data).getData();
//显示图片
Glide.with(this).load(imageUri).int0(ivPicture);
}
}

这里加了一个判断用于检测是否为打开相册之后的返回与返回是否成功。RESULT_OK是Activity中自带的。


  然后在获取数据时判空处理一下再赋值给一个Uri变量,然后通过Glide框架加载这个Url显示在刚才的ivPicture上。代码写好了,下面运行一下:


在这里插入图片描述


嗯,图片显示出来了,图片的url也拿到了,下面该做这个图片的剪裁了。


四、图片裁剪


既然是调用Android系统的图片裁剪,那么自然也和打开系统相册差不多,依然是先创建一个请求码:


	/**
* 图片剪裁请求码
*/

public static final int PICTURE_CROPPING_CODE = 200;

然后写一个裁剪的方法。


	/**
* 图片剪裁
*
* @param uri 图片uri
*/

private void pictureCropping(Uri uri) {
// 调用系统中自带的图片剪裁
Intent intent = new Intent("com.android.camera.action.CROP");
intent.setDataAndType(uri, "image/*");
// 下面这个crop=true是设置在开启的Intent中设置显示的VIEW可裁剪
intent.putExtra("crop", "true");
// aspectX aspectY 是宽高的比例
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
// outputX outputY 是裁剪图片宽高
intent.putExtra("outputX", 150);
intent.putExtra("outputY", 150);
// 返回裁剪后的数据
intent.putExtra("return-data", true);
startActivityForResult(intent, PICTURE_CROPPING_CODE);
}

图片裁剪需要用到uri,再上面打开相册返回时就已经拿到了uri,那么下面修改onActivityResult方法。


	@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == OPEN_ALBUM_CODE && resultCode == RESULT_OK) {
//打开相册返回
final Uri imageUri = Objects.requireNonNull(data).getData();
//图片剪裁
pictureCropping(imageUri);
} else if (requestCode == PICTURE_CROPPING_CODE && resultCode == RESULT_OK) {
//图片剪裁返回
Bundle bundle = data.getExtras();
if (bundle != null) {
//在这里获得了剪裁后的Bitmap对象,可以用于上传
Bitmap image = bundle.getParcelable("data");
//设置到ImageView上
ivPicture.setImageBitmap(image);
}
}
}

  在打开相册返回之后调用pictureCropping方法,传入图片url,然后会启动系统剪裁,剪裁后通过返回数据数据设置到ImageVIew控件上。注意剪裁后就不再是uri了,而是Bitmap。运行一下:


在这里插入图片描述


  可以看到系统的剪裁并不是很彻底,gif中虽然演示的剪裁时是一个圆形,但实际上剪裁的是一个正方形的,这其实和Android系统版本及设置的参数有关系。我在荣耀8和荣耀20i上运行都是这样的,对应的版本是8.0和10.0,效果基本一致。那么下面修改一下参数试试看,如下图我修改了宽高比例和剪裁后的宽高。


在这里插入图片描述


再运行一下:


在这里插入图片描述


可以看到通过该参数真的就不一样了不是吗?


  但是有一些朋友想要圆形的剪裁,那么这里有一个问题你要弄清楚,你要真的还是假的,真的圆形,那么肯定是需要剪裁后重新生成的,而假的圆形就很好办了,首先我们改回刚才的参数,那么在我的是手机上就还是这样的圆形剪裁框,而我只要让他显示出来是一个圆形,你就会以为你是剪裁成功了,当然这都是忽悠用户的好办法,下面来实践一下。这个可以通过外力来解决,圆形图片很多方式能做到,比如第三方框架、自定义View等。


还记得刚才用过的Glide吗?创建requestOptions对象


	/**
* Glide请求图片选项配置
*/

private RequestOptions requestOptions = RequestOptions
.circleCropTransform()//圆形剪裁
.diskCacheStrategy(DiskCacheStrategy.NONE)//不做磁盘缓存
.skipMemoryCache(true);//不做内存缓存

然后在剪裁图片的返回中设置图片


	Glide.with(this).load(image).apply(requestOptions).int0(ivPicture);

在这里插入图片描述


运行一下:


在这里插入图片描述


五、源码


源码地址:PictureCroppingDemo


尾声


  OK,就到这里了。我是初学者-Study,山高水长,后会有期。
此项目并不一定适配所有机型和Android版本,要根据实际情况就改动才行。


作者:初学者_Study
来源:juejin.cn/post/7226894630880460859
收起阅读 »

RecyclerView 实现Item倒计时效果

前言 平时我们做倒计时有很多种方法实现,也相对比较简单。但是最近碰到一个需求,需要在RecyclerView的item中实现倒计时,一般这种场景在电商的业务上应该也会有。那么我们就不能像开始单个倒计时一样简单做了,还需要考虑到viewholder的复用、性能等...
继续阅读 »

前言


平时我们做倒计时有很多种方法实现,也相对比较简单。但是最近碰到一个需求,需要在RecyclerView的item中实现倒计时,一般这种场景在电商的业务上应该也会有。那么我们就不能像开始单个倒计时一样简单做了,还需要考虑到viewholder的复用、性能等问题。


效果


这里可以简单先写个Demo看看效果


bd42682d-57b0-44e4-b569-1c0a1a62142f.gif


功能实现


1. 倒计时功能实现

核心就是开启一个计时器,每秒都更新时间到页面上,这个计时器的实现就很多了,比如直接handler,或者kotlin能用flow去做,或者TimerTask这些也能实现。打个广告,要做精准的倒计时可以看这篇文章juejin.cn/post/714065…


我这里是做Demo演示,为了代码整洁和方便,我就用TimerTask来做。


2. 设计思路

试着想想,你用RecyclerView做倒计时,每个ViewHolder的倒计时时间都不同,难道要在每个ViewHolder中开一个TimerTask来做吗?然后对viewholder的缓存再单独做处理?


我的想法是可以所有Item共用一个倒计时


这个系统有3个重要部分组成:


(1)倒计时的实现,只用一个TimerTask来做一个心跳的效果。每次心跳去更新正在显示的Item页面的倒计时 ,比如你有100个Item,但是显示在屏幕上的只有5个,那我只需要关心这5个Item的时间变动,其他95个没必要做处理。


(2)观察者队列。我的每次心跳都要通知正在显示的Item更新页面,那是不是很明显要通过观察者模式去做。


(3)倒计时时间列表。倒计时也需要用一个列表管理起来,recyclerview的页面显示是根据数据去显示,虽然比如说100个数组只需要5、6个viewholder来复用,但是你的差异数据还是100个数据。


3. 倒计时列表


倒计时列表的实现,我这里是用一个HashMap来实现,因为方便直接获取某个实际Item的当前倒计时的时间。


private val cdMap: HashMap<Long, Long> = HashMap()

我的key假设用一个id来做处理,因为我们的数据结构中基本都会存在id并且id是基础数据类型。


data class RcdItemData(  
var id : Long, // id
var cd : Long // 总倒计时时间
)

添加倒计时


fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {  
if (cdMap.containsKey(id)) {
if (isCover) {
cdMap[id] = totalCd
}
} else {
cdMap[id] = totalCd
}
}

这个isCover是一个策略,adapter数据刷新时是否更新倒计时,这里可以先不用管,可以简单看成


fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {  
if (!cdMap.containsKey(id)) {
cdMap[id] = totalCd
}
}

清除倒计时(比如页面退出时就需要做释放操作)


fun clearCountDown() {  
cdMap.clear()
}

获取某个Item当前倒计时的时间


fun getCountDownById(id: Long): Long? {  
if (cdMap.containsKey(id)) {
return cdMap[id]
}

return null
}

更新时间(随心跳更新所有数据)


private fun updateCdByMap() {  
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}

......
}

这些代码都不难理解,就不过多解释了


4. 观察者数组实现


先创建一个观察者数组


private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()


然后就是最基础的添加观察者和移除观察者操作


fun addHolderObservable(onItemSchedule: OnItemSchedule?) {  
viewHolderObservables.add(onItemSchedule)
}

fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.remove(onItemSchedule)
}

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

通知观察者(通知Item倒计时1秒了,可以刷新页面了)


private fun notifyCdFinish() {  
viewHolderObservables.forEach {
it?.onCdSchedule()
}
}

5. 倒计时心跳实现


前面说了,我们让所有的Item共用一个倒计时,也是通过一个心跳去更新各自倒计时时间


private var task: TimerTask? = null  
private var timer: Timer? = null

开始倒计时


fun startHeartBeat() {  
if (task == null) {
timer = Timer()
task = object : TimerTask() {
override fun run() {
updateCdByMap()
}
}
timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次
}
}

每一秒都会调用updateCdByMap()方法去刷新时间。


private fun updateCdByMap() {  
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}
}

// 更改完数据之后通知观察者
Handler(Looper.getMainLooper()).post {
notifyCdFinish()
}
}

TimerTask会在子线程中进行,所以最后通知观察者的操作需要切到主线程


最后关闭倒计时(页面关闭这些时机调用)


fun closeHeartBeat() {  
task?.cancel()
task = null
timer = null
}

6. 整体功能


因为上面是拆开来解释说明,这里再把整个工具的代码合起来可能会比较好管理。


object RecyclerCountDownManager {  

private var task: TimerTask? = null
private var timer: Timer? = null

// viewHolder观察者
private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()

// 倒计时对象数组
private val cdMap: HashMap<Long, Long> = HashMap()

/**
* 添加viewHolder观察
*/

fun addHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.add(onItemSchedule)
}

fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.remove(onItemSchedule)
}

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

/**
* 添加倒计时对象
* @param totalCd 总倒计时时间
* @param isCover 是否覆盖
*/

fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {
if (cdMap.containsKey(id)) {
if (isCover) {
cdMap[id] = totalCd
}
} else {
cdMap[id] = totalCd
}
}

/**
* 清除倒计时
*/

fun clearCountDown() {
cdMap.clear()
}

/**
* 根据id获取倒计时
*/

fun getCountDownById(id: Long): Long? {
if (cdMap.containsKey(id)) {
return cdMap[id]
}

return null
}

/**
* 开始心跳
*/

fun startHeartBeat() {
if (task == null) {
timer = Timer()
task = object : TimerTask() {
override fun run() {
updateCdByMap()
}
}
timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次
}
}

/**
* 更新所有倒计时对象
*/

private fun updateCdByMap() {
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}
}
// 更改完数据之后通知观察者
Handler(Looper.getMainLooper()).post {
notifyCdFinish()
}
}

private fun notifyCdFinish() {
viewHolderObservables.forEach {
it?.onCdSchedule()
}
}

/**
* 关闭心跳
*/

fun closeHeartBeat() {
task?.cancel()
task = null
timer = null
}

/**
* 调度通知,一般由ViewHolder实现该接口
*/

interface OnItemSchedule {

fun onCdSchedule()

}


}

可以看到代码都整体比较简单,就不用过多说明,就是需要注意一下这个是用一个单例去实现的工具,在页面关闭之后需要手动调用closeHeartBeat()、clearCountDown()、releaseHolderObservable()去释放资源。


调用的地方,Demo的Adapter


class RcdAdapter(var context: Context, var list: List<RcdItemData>) :  
RecyclerView.Adapter<RcdAdapter.RcdViewHolder>() {

init {
// 因为模式默认选择不覆盖,需要每次添加前先清除
RecyclerCountDownManager.clearCountDown()
list.forEach {
RecyclerCountDownManager.addCountDown(it.id, it.cd)
}
}

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RcdViewHolder {
val text: TextView = TextView(context)
text.layoutParams = ViewGr0up.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, 64)
text.gravity = Gravity.CENTER
val holder = RcdViewHolder(text)
RecyclerCountDownManager.addHolderObservable(holder)
return holder
}

override fun getItemCount(): Int {
return list.size
}

override fun onBindViewHolder(holder: RcdViewHolder, position: Int) {
holder.setData(list[position])
}

class RcdViewHolder(var view: TextView) : RecyclerView.ViewHolder(view),
RecyclerCountDownManager.OnItemSchedule {

private var mData: RcdItemData? = null

fun setData(data: RcdItemData) {
mData = data
}

override fun onCdSchedule() {
val cd = mData?.id?.let { RecyclerCountDownManager.getCountDownById(it) }
if (cd != null) {
// 测试展示分秒
view.text = "${String.format("d", cd / 60)}:${String.format("d", cd % 60)}"
}
}

}

}

其他都比较基础的adapter的写法,就是viewholder要实现RecyclerCountDownManager.OnItemSchedule来充当观察者,然后拿到列表数据后调用RecyclerCountDownManager.addCountDown(it.id, it.cd)去创建倒计时列表。在onCreateViewHolder中调用RecyclerCountDownManager.addHolderObservable(holder)去添加观察者。最后在onCdSchedule()回调中做倒计时的更新


image.png


在页面销毁的时候主动释放内存


image.png


作者:流浪汉kylin
来源:juejin.cn/post/7355687352457560116
收起阅读 »

用Kotlin通杀“一切”单位换算

用Kotlin通杀“一切”单位换算之存储容量 前言 在之前的文章《用Kotlin Duration来优化时间单位换算》 中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(长度单位m,cm;质量单位 kg,...
继续阅读 »

用Kotlin通杀“一切”单位换算之存储容量


前言


在之前的文章《用Kotlin Duration来优化时间单位换算》
中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(长度单位m,cm;质量单位 kg,g,lb;存储容量单位 mb,gb,tb 等等)


//进率为1024
val tenMegabytes = 10 * 1024 * 1024 //10mb
val tenGigabytes = 10 * 1024 * 1024 * 1024 //10gb

加入这样的业务代码后阅读性就变差了,能否有像Duration一样的api实现下面这样的代码呢?


fun main() {
1.kg = 2.20462262.lb; 1.m = 100.cm

val fiftyMegabytes = 50.mb
val divValue = fiftyMegabytes - 30.mb
// 20mb
val timesValue = fiftyMegabytes * 2.4
// 120mb

// 1G文件 再增加2个50mb的数据空间
val fileSpace = fiftyMegabytes * 2 + 1.gb
RandomAccessFile("fileName","rw").use {
it.setLength(fileSpace.inWholeBytes)
it.write(...)
}
}

下面我们通过分析Duration源码了解原理,并且实现存储容量单位DataSize的换算和运算。


简单拆解Duration


kotlin没有提供,要做到上面的api那么我不会啊,但是我看到Duration可以做到,那我们来看看它的原理,进行仿写就行了。



  1. 枚举DurationUnit是用来定义时间不同单位,方便换算和转换的(详情看源码或上篇文)。

  2. Duration是如何做到不同单位的数据换算的,先看看Duration的创建函数和构造函数。toDuration把当前的值通过convertDurationUnit把时间换算成nanos或millis的值,再通过shl运算用来记录单位。
    //Long创建 Duration
    public fun Long.toDuration(unit: DurationUnit): Duration {
    //最大支持的 nanos值
    val maxNsInUnit = convertDurationUnitOverflow(MAX_NANOS, DurationUnit.NANOSECONDS, unit)
    //当前值如果在最大和最小值中间 表示不会溢出
    if (this in -maxNsInUnit..maxNsInUnit) {
    //创建 rawValue 是Nanos的 Duration
    return durationOfNanos(convertDurationUnitOverflow(this, unit, DurationUnit.NANOSECONDS))
    } else {
    //创建 rawValue 是millis的 Duration
    val millis = convertDurationUnit(this, unit, DurationUnit.MILLISECONDS)
    return durationOfMillis(millis.coerceIn(-MAX_MILLIS, MAX_MILLIS))
    }
    }
    // 用 nanos
    private fun durationOfNanos(normalNanos: Long) = Duration(normalNanos shl 1)
    // 用 millis
    private fun durationOfMillis(normalMillis: Long) = Duration((normalMillis shl 1) + 1)
    //不同os平台实现,肯定是 1小时60分 1分60秒那套算法
    internal expect fun convertDurationUnit(value: Long, sourceUnit: DurationUnit, targetUnit: DurationUnit): Long


  3. Duration是一个value class用来提升性能的,通过rawValue还原当前时间换算后的nanos或millis的数据value。为何不全部都用Nanos省去了这些计算呢,根据代码看应该是考虑了Nanos的计算会溢出。用一个long值可以还原构造对象前的所有参数,这代码设计真牛逼。
    @JvmInline
    public value class Duration internal constructor(private val rawValue: Long) : Comparable<Duration> {
    //原始最小单位数据
    private val value: Long get() = rawValue shr 1
    //单位鉴别器
    private inline val unitDiscriminator: Int get() = rawValue.toInt() and 1
    private fun isInNanos() = unitDiscriminator == 0
    private fun isInMillis() = unitDiscriminator == 1
    //还原的最小单位 DurationUnit对象
    private val storageUnit get() = if (isInNanos()) DurationUnit.NANOSECONDS else DurationUnit.MILLISECONDS


  4. Duration是如何做到算术运算的,是通过操作符重载实现的。不同单位Duration,持有的数据是同一个单位的那么是可以互相运算的,我们后面会着重介绍和仿写。

  5. Duration是如何做到逻辑运算的(>,<,>=,<=),构造函数实现了接口Comparable<Duration>重写了operator fun compareTo(other: Duration): Int,返回1,-1,0



Duration主要依靠对象内部持有的rawValue: Long,由于value的单位是“相同”的,就可以实现不同单位的换算和运算。



存储容量单位换算设计



  1. 存储容量的单位一般有比特(b),字节(B),千字节(KB),兆字节(MB),千兆字节(GB),太字节(TB),拍字节(PB),艾字节(EB),泽字节(ZB),尧字节(YB)
    ,考虑到实际应用和Long的取值范围我们最大支持PB即可。


    enum class DataUnit(val shortName: String) {
    BYTES("B"),
    KILOBYTES("KB"),
    MEGABYTES("MB"),
    GIGABYTES("GB"),
    TERABYTES("TB"),
    PETABYTES("PB")
    }


  2. 对于存储容量来说最小单位我们就定为Bytes,最大支持到PB,然后可以省去对数据过大的溢出的"单位鉴别器"设计。(注意使用pb时候,>= 8192.pb就会溢出)


    @JvmInline
    value class DataSize internal constructor(private val rawBytes: Long)


  3. 参照Duration在创建和最后单位换算时候都用到了convertDurationUnit函数,接受原始单位和目标单位。另外考虑到可能出现换算溢出使用Math.multiplyExact来抛出异常,防止数据计算异常无法追溯的问题。


    /** Bytes per Kilobyte.*/
    private const val BYTES_PER_KB: Long = 1024
    /** Bytes per Megabyte.*/
    private const val BYTES_PER_MB = BYTES_PER_KB * 1024
    /** Bytes per Gigabyte.*/
    private const val BYTES_PER_GB = BYTES_PER_MB * 1024
    /** Bytes per Terabyte.*/
    private const val BYTES_PER_TB = BYTES_PER_GB * 1024
    /** Bytes per PetaByte.*/
    private const val BYTES_PER_PB = BYTES_PER_TB * 1024

    internal fun convertDataUnit(value: Long, sourceUnit: DataUnit, targetUnit: DataUnit): Long {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> Math.multiplyExact(value, BYTES_PER_KB)
    DataUnit.MEGABYTES -> Math.multiplyExact(value, BYTES_PER_MB)
    DataUnit.GIGABYTES -> Math.multiplyExact(value, BYTES_PER_GB)
    DataUnit.TERABYTES -> Math.multiplyExact(value, BYTES_PER_TB)
    DataUnit.PETABYTES -> Math.multiplyExact(value, BYTES_PER_PB)
    }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }

    internal fun convertDataUnit(value: Double, sourceUnit: DataUnit, targetUnit: DataUnit): Double {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> value * BYTES_PER_KB
    DataUnit.MEGABYTES -> value * BYTES_PER_MB
    DataUnit.GIGABYTES -> value * BYTES_PER_GB
    DataUnit.TERABYTES -> value * BYTES_PER_TB
    DataUnit.PETABYTES -> value * BYTES_PER_PB
    }
    require(!valueInBytes.isNaN()) { "DataUnit value cannot be NaN." }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }


  4. 扩展属性和构造DataSize,rawBytes是Bytes因此所有的目标单位设置为DataUnit.BYTES,而原始单位就通过调用者告诉convertDataUnit


    fun Long.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES))
    }
    fun Double.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES).roundToLong())
    }
    inline val Long.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Long.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Long.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Long.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Long.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Long.pb get() = this.toDataSize(DataUnit.PETABYTES)

    inline val Int.bytes get() = this.toLong().toDataSize(DataUnit.BYTES)
    inline val Int.kb get() = this.toLong().toDataSize(DataUnit.KILOBYTES)
    inline val Int.mb get() = this.toLong().toDataSize(DataUnit.MEGABYTES)
    inline val Int.gb get() = this.toLong().toDataSize(DataUnit.GIGABYTES)
    inline val Int.tb get() = this.toLong().toDataSize(DataUnit.TERABYTES)
    inline val Int.pb get() = this.toLong().toDataSize(DataUnit.PETABYTES)

    inline val Double.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Double.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Double.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Double.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Double.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Double.pb get() = this.toDataSize(DataUnit.PETABYTES)


  5. 换算函数设计
    Duration用toLong(DurationUnit)或者toDouble(DurationUnit)来输出指定单位的数据,inWhole系列函数是对toLong(DurationUnit) 的封装。toLong和toDouble实现就比较简单了,把convertDataUnit传入输出单位,而原始单位就是rawValue的单位DataUnit.BYTES
    toDouble需要输出更加精细的数据,例如: 512mb = 0.5gb。


    val inWholeBytes: Long
    get() = toLong(DataUnit.BYTES)
    val inWholeKilobytes: Long
    get() = toLong(DataUnit.KILOBYTES)
    val inWholeMegabytes: Long
    get() = toLong(DataUnit.MEGABYTES)
    val inWholeGigabytes: Long
    get() = toLong(DataUnit.GIGABYTES)
    val inWholeTerabytes: Long
    get() = toLong(DataUnit.TERABYTES)
    val inWholePetabytes: Long
    get() = toLong(DataUnit.PETABYTES)

    fun toDouble(unit: DataUnit): Double = convertDataUnit(bytes.toDouble(), DataUnit.BYTES, unit)
    fun toLong(unit: DataUnit): Long = convertDataUnit(bytes, DataUnit.BYTES, unit)



操作符设计


在Kotlin 中可以为类型提供预定义的一组操作符的自定义实现,被称为操作符重载。这些操作符具有预定义的符号表示(如 + 或
*)与优先级。为了实现这样的操作符,需要为相应的类型提供一个指定名称的成员函数或扩展函数。这个类型会成为二元操作符左侧的类型及一元操作符的参数类型。


如果函数不存在或不明确,则导致编译错误(编译器会提示报错)。下面为常见操作符对照表:


操作符函数名说明
+aa.unaryPlus()一元操作 取正
-aa.unaryMinus()一元操作 取负
!aa.not()一元操作 取反
a + ba.plus(b)二元操作 加
a - ba.minus(b)二元操作 减
a * ba.times(b)二元操作 乘
a / ba.div(b)二元操作 除

算术运算支持



  1. 这里用算术运算符+实现来举例:假如DataSize对象需要重载操作符+
    val a = DataSize(); val c: DataSize = a + b


  2. 需要定义扩展函数1或者添加成员函数2
    1. operator fun DataSize.plus(other: T): DataSize {...}
    2. class DataSize { operator fun plus(other: T): DataSize {...} }


  3. 函数中的参数other: T表示b的对象类型,例如
    // val a: DataSize; val b: DataSize; a + DataSize()
    operator fun DataSize.plus(other: DataSize): DataSize {...}
    // val a: DataSize; val b: Int; a + 1
    operator fun DataSize.plus(other: Int): DataSize {...}


  4. 为了阅读性,Duration不会和同类型的对象乘除法运算,而使用了Int或Double,因此重载运算符用了operator fun times(scale: Int): Duration

  5. 那么在DataSize中我们也重载(+,-,*,/),并且(*,/)重载的参数只支持Int和Double即可
    operator fun unaryMinus(): DataSize {
    return DataSize(-this.bytes)
    }
    operator fun plus(other: DataSize): DataSize {
    return DataSize(Math.addExact(this.bytes, other.bytes))
    }

    operator fun minus(other: DataSize): DataSize {
    return this + (-other) // a - b = a + (-b)
    }

    operator fun times(scale: Int): DataSize {
    return DataSize(Math.multiplyExact(this.bytes, scale.toLong()))
    }

    operator fun div(scale: Int): DataSize {
    return DataSize(this.bytes / scale)
    }

    operator fun times(scale: Double): DataSize {
    return DataSize((this.bytes * scale).roundToLong())
    }

    operator fun div(scale: Double): DataSize {
    return DataSize((this.bytes / scale).roundToLong())
    }

    上面的操作符重载中minus(),我们使用了 plus()unaryMinus()重载组合a-b = a+(-b),这样我们可以多一个-DataSize的操作符


逻辑运算支持



  • (>,<,>=,<=)让DataSize构造函数实现了接口Comparable<DataSize>重写了operator fun compareTo(other: DataSize): Int,返回rawBytes对比值即可。

  • (==,!=)通过equals(other)函数实现的,value class默认为rawBytes的对比,可以通过java字节码看到。kotlin 1.9之前不支持重写value classs的equals和hashCode


    value class DataSize internal constructor(private val bytes: Long) : Comparable<DataSize> {
    override fun compareTo(other: DataSize): Int {
    return this.bytes.compareTo(other.bytes)
    }
    //示例
    600.mb > 0.5.gb //true
    512.mb == 0.5.gb




操作符重载的目的是为了提升阅读性,并不是所有对象为了炫酷都可以用操作符重载,滥用反而会增加代码的阅读难度。例如给DataSize添加*操作符,5mb * 2mb 就让人头大。



获取字符串形式


为了方便打印和UI展示,一般我们需要重写toSting。Duration的toSting不需要指定输出单位,可以详细的输出当前对象的字符串格式(1h 0m 45.677s)算法比较复杂。我不太会,就简单实现指定输出单位的toString(DataUnit)


 override fun toString(): String = String.format("%dB", rawBytes)

fun toString(unit: DataUnit, decimals: Int = 2): String {
require(decimals >= 0) { "decimals must be not negative, but was $decimals" }
val number = toDouble(unit)
if (number.isInfinite()) return number.toString()
val newDecimals = decimals.coerceAtMost(12)
return DecimalFormat("0").run {
if (newDecimals > 0) minimumFractionDigits = newDecimals
roundingMode = RoundingMode.HALF_UP
format(number) + unit.shortName
}
}

单元测试


功能都写好了需要验证期望的结果和实现的功能是否一直,那么这个时候就用单元测试最好来个100%覆盖。


class ExampleUnitTest {
@Test
fun data_size() {
val dataSize = 512.mb

println("format bytes:$dataSize")
// format bytes:536870912B
println("format kb:${dataSize.toString(DataUnit.KILOBYTES)}")
// format kb:524288.00KB
println("format gb:${dataSize.toString(DataUnit.GIGABYTES)}")
// format gb:0.50GB
// 单位换算
assertEquals(536870912, dataSize.inWholeBytes)
assertEquals(524288, dataSize.inWholeKilobytes)
assertEquals(512, dataSize.inWholeMegabytes)
assertEquals(0, dataSize.inWholeGigabytes)
assertEquals(0, dataSize.inWholeTerabytes)
assertEquals(0, dataSize.inWholePetabytes)
}

@Test
fun data_size_operator() {
val dataSize1 = 512.mb
val dataSize2 = 3.gb

val unaryMinusValue = -dataSize1 //取负数
println("unaryMinusValue :${unaryMinusValue.toString(DataUnit.MEGABYTES)}")
// unaryMinusValue :-512.00MB

val plusValue = dataSize1 + dataSize2 //+
println("plus :${plusValue.toString(DataUnit.GIGABYTES)}")
// plus :3.50GB

val minusValue = dataSize1 - dataSize2 // -
println("minus :${minusValue.toString(DataUnit.GIGABYTES)}")
// minus :-2.50GB

val timesValue = dataSize1 * 2 //乘法
println("times :${timesValue.toString(DataUnit.GIGABYTES)}")
// times :1.00GB

val divValue = dataSize2 / 2 //除法
println("div :${divValue.toString(DataUnit.GIGABYTES)}")
// div :1.50GB
}

@Test(expected = ArithmeticException::class)
fun data_size_overflow() {
8191.pb
8192.pb //溢出了不支持,如果要支持参考"单位鉴别器"设计
}

@Test
fun data_size_compare() {
assertTrue(600.mb > 0.5.gb)
assertTrue(512.mb == 0.5.gb)
}
}

总结


通过学习Kotlin Duration的源码,举一反三应用到储存容量单位转换和运算中。Duration中的拆解计算api,还有toSting算法实现就留给大家学习吧。当然了你也可以实现和Duration一样更加精细的"单位鉴别器"设计,支持ZB、YB等大单位。


另外类似的进率场景也可以实现,用Kotlin通杀“一切进率换算”。比如Degrees角度计算 -90.0.degrees == 270.0.degrees;质量计算kg和磅,两等等1.kg == 2.20462262.lb;甚至人民币汇率 (动态实现算法)8.dollar == 1.rmb 🐶


github 代码: github.com/forJrking/K…


操作符重载文档: book.kotlincn.net/text/operat…


作者:forJrking
来源:juejin.cn/post/7301145359852765218
收起阅读 »

Android自定义定时通知实现

Android自定义通知实现 前言 自定义通知就是使用自定义的布局,实现自定义的功能。 Notification的常规布局中可以设置标题、内容、大小图标,也可以实现点击跳转等。 常规的布局固然方便,可当需求变多就只能使用自定义布局了。 我想要实现的通知布局除了...
继续阅读 »

Android自定义通知实现


前言


自定义通知就是使用自定义的布局,实现自定义的功能。


Notification的常规布局中可以设置标题、内容、大小图标,也可以实现点击跳转等。


常规的布局固然方便,可当需求变多就只能使用自定义布局了。


我想要实现的通知布局除了时间、标题、图标、跳转外,还有“5分钟后提醒”、“已知悉”这两个功能需要实现。如下图所示:


nnotify.gif

正文


一、待办数据库


1、待办实体类


image.png

假设现在时间是12点,添加了一个14点的待办会议,并设置提前20分钟进行提醒


其中year、month、day、time构成会议的时间,也就是今天的14点content是待办的内容。remind是该待办提前提醒的时间,也就是20分钟type是待办类型,包括未完成,已完成和忽略


2、数据访问Dao


添加或更新一条待办事项


image.png



@Insert(onConflict = OnConflictStrategy.REPLACE) 注解: 如果指定 id 的对象没有保存在数据库中, 就会新增一条数据到数据库。如果指定 id 的对象数据已经保存到数据库中, 就会删除掉原来的数据, 然后新增一条数据。



3、数据库封装


在仓库层的TodoStoreRepository类中对待办数据库的操作进行封装。不赘述了。


二、添加定时器


每条待办都对应着一个定时任务,在用户添加一条待办数据的同时需要添加一条对应的定时任务。在这里使用映射的方式,将待办的Id和定时任务一一绑定。


1、思路:



  1. 首先要构造一个Map<Long, TimerTask>>类型的参数和一个定时器Timer。

  2. 在添加定时任务前,先对待办数据进行过滤。

  3. 计算出延迟的时间。

  4. 定时器调度,当触发时,消息弹窗提醒。


2、实现


image.png


说明



  • isOver() -- 判断该待办有没有完成,通过待办的type进行判断。



代码:fun isOver() = type == 1




  • delayTime -- 延迟时间。获取到当前时间的时间戳、将待办提醒的时间转换为时间戳,最后相减,得到一个Long类型的时间戳即延迟时间。

  • 当delayTime大于0时,进行定时器调度,将待办Id与定时任务绑定,当延迟时间到达时会触发定时器任务,定时器任务中包含了消息弹窗提醒。


3、封装


image.png



在 TodoScheduleUseCase 类中将待办数据插入和待办定时器创建封装在一起了,插入数据后获取到数据的Id,将id赋值传给待办定时器任务。



三、注册广播


1. 首先创建一个TodoNotifyReceiver广播


image.png



在TodoNotifyReceiver中,首先获取到待办数据,根据Action判断广播的类型并执行相应的回调。



2. 自定义Action


分别是“5分钟后提醒”、“已知悉”的Action


image.png


3. 广播注册方法


image.png

4.广播注册及回调实现


“5分钟后提醒”实现是调用delayTodoTask5min方法,原理就是将remind即提醒时间减5达到五分钟后提醒的效果。并取消该通知。再将修改过属性的待办重新添加到待办列表中。


“已知悉”实现是调用markTodoTaskDone方法,原理就是将type属性标记成1,代表已完成。并取消该通知。再将修改过属性的待办重新添加到待办列表中。


image.png


/**
* 延迟5分钟
*/

fun delayTodoTask5min(todoInfo: TodoInfo) {
useScope.launch {
todoInfo.remind -= 5
insertOrUpdateTodo(todoInfo)
}
}

/**
* 标记已完成
*/

fun markTodoTaskDone(todoInfo: TodoInfo) {
useScope.launch {
todoInfo.type = 1
insertOrUpdateTodo(todoInfo)
}
}



四、自定义通知构建


fun showNotify(todoInfo: TodoInfo) {
binding = LayoutTodoNotifyItemBinding.inflate(context.layoutInflater())

// 自定义通知布局
val notificationLayout =
RemoteViews(context.packageName, R.layout.layout_todo_notify_item)
// 设置自定义的Action
val notifyAfterI = Intent().setAction(TodoNotifyReceiver.TODO_CHANGE_ACTION)
val alreadyKnowI = Intent().setAction(TodoNotifyReceiver.TODO_ALREADY_KNOW_ACTION)
// 传入TodoInfo
notifyAfterI.putExtra("todoInfo", todoInfo)
alreadyKnowI.putExtra("todoInfo", todoInfo)
// 设置点击时跳转的界面
val intent = Intent(context, MarketActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, todoInfo.id.toInt(), intent, PendingIntent.FLAG_CANCEL_CURRENT)
val notifyAfterPI = PendingIntent.getBroadcast(context, todoInfo.id.toInt(), notifyAfterI, PendingIntent.FLAG_CANCEL_CURRENT)
val alreadyKnowPI = PendingIntent.getBroadcast(context, todoInfo.id.toInt(), alreadyKnowI, PendingIntent.FLAG_CANCEL_CURRENT)

//给通知布局中的组件设置点击事件
notificationLayout.setOnClickPendingIntent(R.id.notify_after, notifyAfterPI)
notificationLayout.setOnClickPendingIntent(R.id.already_know, alreadyKnowPI)

// 构建自定义通知布局
notificationLayout.setTextViewText(R.id.notify_content, todoInfo.content)
notificationLayout.setTextViewText(R.id.notify_date, "${todoInfo.year}-${todoInfo.month + 1}-${todoInfo.day} ${todoInfo.time}")

var notifyBuild: NotificationCompat.Builder? = null
// 构建NotificationChannel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(context.packageName, "todoNotify", NotificationManager.IMPORTANCE_HIGH)
notificationChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET
notificationChannel.enableLights(true) // 是否在桌面icon右上角展示小红点
notificationChannel.lightColor = Color.RED// 小红点颜色
notificationChannel.setShowBadge(true) // 是否在久按桌面图标时显示此渠道的通知
notificationManager.createNotificationChannel(notificationChannel)
notifyBuild = NotificationCompat.Builder(context, todoInfo.id.toString())
notifyBuild.setChannelId(context.packageName);
} else {
notifyBuild = NotificationCompat.Builder(context)
}
notifyBuild.setSmallIcon(R.mipmap.icon_todo_item_normal)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomContentView(notificationLayout) //设置自定义通知布局
.setPriority(NotificationCompat.PRIORITY_MAX) //设置优先级
.setAutoCancel(true) //设置点击后取消Notification
.setContentIntent(pendingIntent) //设置跳转
.build()
notificationManager.notify(todoInfo.id.toInt(), notifyBuild.build())

// 取消指定id的通知
fun cancelNotifyById(id: Int) {
notificationManager.cancel(id)
}
}


步骤:



  1. 构建自定义通知布局

  2. 设置自定义的Action

  3. 传入待办数据

  4. 设置点击时跳转的界面

  5. 设置了两个BroadcastReceiver类型的点击回调

  6. 给通知布局中的组件设置点击事件

  7. 构建自定义通知布局

  8. 构建NotificationChannel

  9. 添加取消指定id的通知方法



总结


以上,Android自定义定时通知实现的过程和结果。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7278566669282787383
收起阅读 »

Android 使用TextView实现验证码输入框

前言 网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下 1、数字 / 字符键盘切换后键盘状态无法保存 2、焦点切换无法判断 3、光标位置无法修正 4、切换过程需要做很多同步工作 5、需要处理聚焦选中区域问题 6、性能差 EditText...
继续阅读 »

前言


网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下


1、数字 / 字符键盘切换后键盘状态无法保存

2、焦点切换无法判断

3、光标位置无法修正

4、切换过程需要做很多同步工作

5、需要处理聚焦选中区域问题

6、性能差


EditText越多,造成的不确定性问题将越多,因此,在开发中,如果我们自行实现一个纯View的输入框有没有可能呢?比较遗憾的是,Android 层面android.widget.Editor是非公开的类,因此很难去实现一个想要的View。


另一种方案,我们继承TextView,改写TextView的绘制逻辑也是可以。


为什么TextView是可以的呢?



  • 第一:TextView 本身可以输入任何文本

  • 第二:TextView 绘制方法中使用android.widget.Editor可以辅助keycode->文本转换

  • 第三:TextView 提供了光标等各种组件


核心步骤


为了解决上述问题,使用 TextView 实现输入框,这里需要解决的问题是


1、允许 TextView 可编辑输入,这点可以参考EditText的实现

2、重写 onDraw 实现,不实用原有的绘制逻辑。

3、重写光标逻辑,默认的光标逻辑和Editor有很多关联逻辑,而Editor是@hide标注的,因此必须要重写

4、重写长按菜单逻辑,防止弹出剪切、复制、选中等PopWindow弹窗。
5、限制文本长度


fire_89.gif


代码实现


首先我们要继承TextView或者AppCompatTextView,然后实现下面的操作


变量定义


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//是否展示光标
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;

关键状态


禁止复制、粘贴、选中


mrb62ges5a.jpeg


super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为聚焦的view必须是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

绘制逻辑


我们重写onDraw方法,自行绘制View


TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();
//获取默认风格
Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);

InsertionHandleView问题


image.png


我们上文处理了各种可能出现的选中区域弹窗,然而一个很难处理的弹窗双击后会展示,评论区有同学也贴出来了。主要原因是Editor为了方便EditText选中,在内部使用了InsertionHandleView去展示一个弹窗,但这个弹窗并不是直接addView的,而是通过PopWindow展示的,具体可以参考下面源码。


实际上,掘金Android 客户端也有类似的问题,不过掘金app的实现方式是使用多个EditText实现的,点击的时候就会明显看到这个小雨点,其次还有光标卡顿的问题。



android.widget.Editor.InsertionHandleView



解决方法其实有3种:


第一种是Hack Context,返回一个自定义的WindowManager给PopWindow,不过我们知道InputManagerService 作为 WindowManagerService中的子服务,如果处理不当,可能产生输入法无法输入的问题,另外要Hack WindowManager,显然工作量很大。


第二种是替换:修改InsertionHandleView的背景元素,具体可参考:blog.csdn.net/shi_xin/art… 一文


<item name="textSelectHandleLeft">@drawable/text_select_handle_left_material</item>
<item name="textSelectHandleRight">@drawable/text_select_handle_right_material</item>
<item name="textSelectHandle">@drawable/text_select_handle_middle_material</item>

这种方式增加了View的可扩展性,自定义View要尽可能避免和xml配置耦合,除非是自定义属性。


第三种是拦截hide方法,在popWindow展示之后,会立即设置一个定时消失的逻辑,这种相对简单,而且View的通用性不受影响,但是也有些不规范,不过目前这个调用还是相当稳定的。


综上,我们选择第三种方案,我这里直接拦截其内部调用postDelay的方法,如果是InsertionHandleView的内部类,且时间为4000秒,直接执行runnable


private void hideAfterDelay() {
if (mHider == null) {
mHider = new Runnable() {
public void run() {
hide();
}
};
} else {
removeHiderCallback();
}
mTextView.postDelayed(mHider, DELAY_BEFORE_HANDLE_FADES_OUT);
}

下面是解法:


@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
Log.d("TAG","delayMillis = " + delayMillis);
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

总结


上面就是本文的核心逻辑,实际上EditText、Button都继承自TextView,因此我们简单的修改就能让其支持输入,主要原因还是TextView复杂的设计和各种Layout的支持,但是这也给TextView带来了性能问题。


这里简单说下TextView性能优化,对于单行文本和非可编辑文本,最好是自行实现,单行文本直接用canvas.drawText绘制,当然多行也是可以的,不过鉴于要支持很多特性,多行文本可以使用StaticLayout去实现,但单行文本尽量自己绘制,也不要使用BoringLayout,因为其存在一些兼容性问题,另外自定义的单行文本不要和TextView同一行布局,因为TextView的计算相对较多,很可能产生对不齐的问题。


本篇全部代码


按照惯例,这里依然提供全部代码,仅供参考,当然,也可以直接使用到项目中,本篇代码在线上已经使用过。


public class EditableTextView extends TextView {

private RectF inputRect = new RectF();


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//光标闪烁控制
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;
// box radius
private float boxRadius = dp2px(0);

InputFilter[] inputFilters = new InputFilter[]{
new InputFilter.LengthFilter(inputBoxNum)
};


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

public EditableTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public EditableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为在触屏模式可聚焦的view一般是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

Drawable cursorDrawable = getTextCursorDrawable();
if(cursorDrawable == null){
cursorDrawable = new PaintDrawable(Color.MAGENTA);
setTextCursorDrawable(cursorDrawable);
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
super.setPointerIcon(null);
}
super.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
return true; //抑制长按出现弹窗的问题
}
});

//禁用ActonMode弹窗
super.setCustomSelectionActionModeCallback(null);

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE);
}
mBoxSpace = (int) dp2px(10f);

}

@Override
public ActionMode startActionMode(ActionMode.Callback callback) {
return null;
}

@Override
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
return null;
}

@Override
public boolean hasSelection() {
return false;
}

@Override
public boolean showContextMenu() {
return false;
}

@Override
public boolean showContextMenu(float x, float y) {
return false;
}

public void setBoxSpace(int mBoxSpace) {
this.mBoxSpace = mBoxSpace;
postInvalidate();
}

public void setInputBoxNum(int inputBoxNum) {
if (inputBoxNum <= 0) return;
this.inputBoxNum = inputBoxNum;
this.inputFilters[0] = new InputFilter.LengthFilter(inputBoxNum);
super.setFilters(inputFilters);
}

@Override
public void setClickable(boolean clickable) {

}

@Override
public void setLines(int lines) {

}
@Override
protected boolean getDefaultEditable() {
return true;
}


@Override
protected void onDraw(Canvas canvas) {

TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();

Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);
}


private Runnable invalidateCursor = new Runnable() {
@Override
public void run() {
invalidate();
}
};


//避免paint.getFontMetrics内部频繁创建对象
Paint.FontMetrics fm = new Paint.FontMetrics();

/**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/


public float getTextPaintBaseline(Paint p) {
p.getFontMetrics(fm);
Paint.FontMetrics fontMetrics = fm;
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

/**
* 控制是否保存完整文本
*
* @return
*/

@Override
public boolean getFreezesText() {
return true;
}

@Override
public Editable getText() {
return (Editable) super.getText();
}

@Override
public void setText(CharSequence text, BufferType type) {
super.setText(text, BufferType.EDITABLE);
}

/**
* 控制光标展示
*
* @return
*/

@Override
protected MovementMethod getDefaultMovementMethod() {
return ArrowKeyMovementMethod.getInstance();
}

@Override
public boolean isCursorVisible() {
return isCursorVisible;
}

@Override
public void setTextCursorDrawable(@Nullable Drawable textCursorDrawable) {
// super.setTextCursorDrawable(null);
this.textCursorDrawable = textCursorDrawable;
postInvalidate();
}

@Nullable
@Override
public Drawable getTextCursorDrawable() {
return textCursorDrawable; //支持android Q 之前的版本
}

@Override
public void setCursorVisible(boolean cursorVisible) {
isCursorVisible = cursorVisible;
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

public void setBoxRadius(float boxRadius) {
this.boxRadius = boxRadius;
postInvalidate();
}

public void setBoxColor(int boxColor) {
this.boxColor = boxColor;
postInvalidate();
}

public void setCursorHeight(float cursorHeight) {
this.cursorHeight = cursorHeight;
postInvalidate();
}

public void setCursorWidth(float cursorWidth) {
this.cursorWidth = cursorWidth;
postInvalidate();
}

@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

}


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

生产环境中的console.log语句会导致内存泄漏,一定不要用!!!

web
前言 如果要在 JS 中找一个用的最多的函数,那一定就是console.log,在前端进行调试时,大家都屡试不爽,都喜欢用的函数。但是在生产环境中使用console.log之类的打印日志,这就会造成内存的泄漏了,这是我们不可以忽视的一个点。 为什么会造成内存泄...
继续阅读 »

前言


如果要在 JS 中找一个用的最多的函数,那一定就是console.log,在前端进行调试时,大家都屡试不爽,都喜欢用的函数。但是在生产环境中使用console.log之类的打印日志,这就会造成内存的泄漏了,这是我们不可以忽视的一个点。


为什么会造成内存泄漏呢?接下来我们来分析分析。


先来这样的一个场景


<body>
<h1 id="app" @click="handleClick"> Hello, console.log</h1>

<script>
const h1 = document.getElementById('app');

h1.addEventListener('click', () => {
const arr = new Array(100000).fill(0);
console.log(arr);
})
</script>
</body>

每当我们点击一次<h1>元素时,就会创建了一个包含 100000 个元素的数组,并将其输出到控制台中。


GIF 2024-4-9 18-20-27.gif


我们知道打印在控制台上的数组,我们是可以将它展开来看见更加详细的内容的,所以造成内存泄漏的原因是什么呢?


按照过程,点击一下,触发一个事件处理函数,待这个函数执行完之后,里面的生成的数组按道理是要销毁掉的,但是因为经过了打印,控制台里面需要保持对这个数组的引用, 不然的话我们就不能展开数组,查看里面的内容了,所以它会一直保存,随着我们点击次数的增多,这样的数组引用次数越来越多,于是就造成了内存泄漏。


接下来我们借助Performance来具体的展示一下是不是这样的情况。


在进行前我们先进行一下垃圾回收(图片中小扫把就是垃圾回收),释放一下内存以便为了更好的观察console.log带来的内存泄漏,然后点击几次h1元素,打印数组,最后再进行一次垃圾回收


GIF 2024-4-9 18-40-21.gif


我们就可以看到,即使我们最后点了垃圾回收,还是存在一部分东西没有被回收,也是占用着内存的,这里指的就是我们打印在控制台的数组了。


0c065197df9c917bb3f467cb7c1ee77.png


我们来个不打印数组的情况看看(操作过程和前面一样,这里只展示最后的结果)


12762d42e5c30b6d6690d79179a1ac9.png


这时我们就可以观察到,内存的增长和下降都是很正常的,每当我们点击一次h1元素,就执行一次事件处理函数,导致内存的占用,可是执行完之后,内存就立马释放出来了。最后点击一次垃圾回收,内存的占用也就和刚刚开始时一样了。


那么说,我们不打开控制台不就不会造成内容泄漏了?那确实,在谷歌浏览器中会进行特殊的处理,并不会造成内存泄漏,但是在别的浏览器中,情况就不一样了。


结尾 🌸🌸🌸


看完这篇文章,我们一定要注意不要在生产环境中使用console.log!不要在生产环境中使用console.log!不要在生产环境中使用console.log!重要的事情说三遍。


但是在开发环境中我们要使用console.log来调试代码怎么办呢?那就需要在打包到生产环境时,把这个console.log给去掉,手动删的话又太麻烦了,这时就可以借助terser工具来帮助我们了。


好的,今日分享到此结束,最后感谢小伙伴的阅读。


作者:Ywis
来源:juejin.cn/post/7355763456081313832
收起阅读 »

Android:监听滑动控件实现状态栏颜色切换

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。 今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图: 看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。



今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图:


1.gif


看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动到底部的时候背景色变为白色或者其他颜色的时候,状态栏颜色不跟随切换颜色有可能会显得难看至极。因此有了上图的效果,其实就是简单的实现了状态栏颜色切换的功能,效果看起来不至于那么尴尬。


首先,我们需要分析,其中需要用到哪些东西呢?



  • 沉浸式状态栏

  • 滑动组件监听


关于沉浸式状态栏,这里推荐使用immersionbar,一款非常不错的轮子。我们只需要将mannifests中主体配置为NoActionBar类型,再根据文档配置好状态栏颜色等属性即可快速得到沉浸式效果:


<style name="Theme.MyApplication" parent="Theme.AppCompat.Light.NoActionBar">

//基础设置
ImmersionBar.with(this)
.navigationBarColor(R.color.color_bg)
.statusBarDarkFont(true, 0.2f)
.autoStatusBarDarkModeEnable(true, 0.2f)//启用自动根据StatusBar颜色调整深色模式与亮式
.autoNavigationBarDarkModeEnable(true, 0.2f)//启用自动根据NavigationBar颜色调整深色式
.init()

//状态栏view
status_bar_view?.let {
ImmersionBar.setStatusBarView(this, it)
}

//xml中状态栏
<View
android:id="@+id/status_bar_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#b8bfff" />

关于滑动监听,我们都知道滑动控件有个监听滑动的方法OnScrollChangeListener,其中返回了Y轴滑动距离的参数。那么,我们可以根据这个参数进行对应的条件判断以达到动态修改状态栏的颜色。


scroll?.setOnScrollChangeListener { _, _, scrollY, _, _ ->
if (scrollY > linTop!!.height) {
if (!isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#ffffff")
)
isChange = true
}
} else {
if (isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#b8bfff")
)
isChange = false
}
}
}

这里判断滑动距离达到紫色视图末端时修改状态栏颜色。因为是在回调方法中,所以这里一旦滑动就在不停触发,所以给了一个私有属性进行不必要的操作,仅当状态改变时且滑动条件满足时才能修改状态栏。当然在这个方法内大家可以发挥自己的想象力做出更多的新花样来。


注意:



  • 滑动监听的这个方法只能在设备6.0及以上才能使用。

  • 需要初始化滑动控件的默认位置,xml中将焦点设置到其父容器中,防止滑动控件不再初始位置。


//初始化位置
scroll?.smoothScrollTo(0, 0)

//xml中设置父view焦点
android:focusable="true"
android:focusableInTouchMode="true"

好了,以上便是滑动控件中实现状态栏切换的简单实现,希望对大家有所帮助。


作者:似曾相识2022
来源:juejin.cn/post/7272229204870561850
收起阅读 »

更适合年轻人体质的 git 工作流

关于如何使用 git,相信大家都见过下面这张图: 很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道...
继续阅读 »

关于如何使用 git,相信大家都见过下面这张图:


image.png


很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道有没有更好的流程可以用。本篇文章就来聊一聊这个问题。


现有 git flow 存在的问题


首先我们来分析一下上面这个流程中都存在哪些问题:


feature 分支要从 dev 分支创建,怎么保证代码是干净的?


举个例子,你要开发一个新功能,从 dev 切出一个新分支后之后发现怎么都跑不起来,群里问了一圈发现有人提交到 dev 的代码有问题,于是你就等到他重新提交了一个 commit 之后,你拉了下代码,这才开始正常开发。


发版时需要从 dev 分支创建 release 分支,怎么保证代码都是干净的?


再举个例子,本轮迭代共提交了 20 个 commit,其中 16 个 commit 需要发布,剩下 4 个 commit 因为还没测试完、bug 没改完不能发。这时候你能准确的把要发布的 commit 检出来么?


如果可以的话,咱们更进一步,本轮迭代由五位同事提交了 40 个 commit,在发版的时候其中两个请假了,这时候你能准确的知道哪些 commit 是要发布的,并准确将其检出来么?


如果还可以的话,那就更更进一步,你检出来之后,发布到 uat 环境,发现代码跑不起来了,结果发现,有个同事偷懒了,某个 commit 因为功能没开发完所以没有检进来,但是恰好这个 commit 里又包含了一些非常关键的代码,没有就跑不起来,这时候你会怎么做?


需要保持 dev 分支和 master 分支的同步,不同步的话可能导致合并冲突


回忆一下,你之前有没有处理过这种合并冲突:冲突的两方代码是完全一样的,但就是冲突了。


这种就是使用了 rebase(非 fast-forward)或者 cherry-pick 后导致的,因为这两种方法会产生代码完全一样,但是 id 不同的新 commit。就导致了 git 产生了混乱。


一个常见场景就是 hotfix 分支的 commit,合并到 master 之后又 cherry-pick 到了 dev 分支。这样下次再从 dev 往 master 合的时候就会出现这种问题。




不知道你什么感受,反正我是已经开始汗流浃背了,那么有没有更简单、更高效、心智负担更低的 git 工作流能解决这些问题呢?当然是有的。


正式介绍一下新的 git flow


首先我们还是以流程图的形式展示一下新的 flow:


image.png


和原本 git flow 的区别在于:



  • feature 分支不再从 dev 创建,而是从最稳定的 master 分支创建

  • dev 分支的代码不再向 release 分支合并,由 feature 直接发起到 release 的合并。

  • 当 release 分支测试完成要发版的时候,直接 fast-forward 到 master

  • 定期删除 dev 和 release,然后从 master 创建新的(例如每轮迭代结束之后)


那么这套工作流能解决刚才提到的问题么?答案是肯定的,老的工作流中存在的问题主要就是:


dev 分支过于重要


dev 需要接受来自多个 feat 以及 hotfix、master 的合并,并合并到 release 分支,这就会导致 dev 分支出现冲突的概率是成倍增加的。开发人员越多,其中存在的脏代码就越多,分支就越不稳定,冲突的情况就越多。


而这套新流程中 dev 的职责被弱化了,变得更加纯粹,即只对接测试环境的发布,其他的工作一概不管。也就是说 dev 本身就是合并路径的终点,从而消除了合并 commit 的回环,干掉了很多可能会产生迷惑冲突的场景。


从普通开发人员的视角看一下


现在我们从头开始,以普通开发的身份来走一遍这套流程,看会有什么效果:



  • 昨天版本发布了,master 代码上有了新的 commit,于是你执行了 git fetch会把远程的代码都同步到本地,比如远程的 master 分支同步到本地的 origin/master

  • 早上开会的时候给你安排了功能 a 和功能 b,你决定先做 a,于是你执行了 git checkout -b feat/a origin/master从刚才拉下来的 origin/master 分支创建了一个新分支

  • 你开始开发,随着开发进度的增加,中间可能执行了多次 git addgit commit

  • 几个小时后终于把功能做好了,自测也没问题,你决定发到测试环境让 QA 同事看一下,于是你执行了 git push 并且在远程仓库里提交了 feat/a 到 dev 分支的 pr,合并完成后流水线自动把代码发布到了测试环境。

  • 通知了 QA 之后,你决定开始开发 b 功能,于是你执行了 git checkout -b feat/b origin/master,然后开始开发。

  • 突然 QA 通知你功能 a 有 bug 需要修复,于是你执行了 git stash 把当前手头的工作暂存了起来,然后 git checkout feat/a 开始解决 bug。

  • 解决完了之后,你重新 git commitgit push 到了 dev 分支,QA 开始继续测试,你也切回了 feat/b 分支并 git stash pop 开始继续开发。

  • 过了一会,QA 通知你功能 a 测试没问题了,于是你在远程仓库里找到 feat/a 分支,并直接发起了一个到 release 分支的 pr。此时 release 分支触发了流水线,将功能 a 的代码更新到了预发环境。

  • 搞完之后,你切回 feat/b 分支继续开始功能 b 的开发...


故事到这里就结束了,你可能会好奇:版本发布的时候呢?不需要执行什么操作?


是的不需要。这套流程中发布生产环境极其简单。因为功能测试完成后会直接推到 release 分支。也就是说,只要和 release 分支绑定的环境(例如 uat)测试没问题,那么发布的时候只需要把 release 合并到 master 就行了。不会出现之前那种要在发版前检查很久要发布哪些 commit 的情况。


一些疑问解答


在实践过程中也有很多同事对这套流程产生了或多或少的疑问,这里就记录一下,希望对大家有帮助:


1、代码提交到 release 分支后出现 bug 怎么办?


切换到对应的分支(例如 feat/c),提交新的 commit 之后从 feat/c 合并到 dev,dev 测试没问题后从 feat/c 合并到 release 分支。


2、feat 分支合并到 dev 分支的时候代码冲突了怎么办?



首先,代码冲突很正常,没有任何一个工作流能完全避免代码冲突。我们应该尽力避免因工作流本身的问题产生的“令人困惑”的代码冲突。



比较正规的做法是:从最新的 dev 创建一个新分支,例如 dev-feat/a,然后把你的 feat/a 本地合并到 dev-feat/a 并解决冲突,然后 git push dev-feat/a 并在远程仓库发起 dev-feat/a 到 dev 的 pr。


比较随性的做法是:本地切到 dev 分支,git pull --rebase 拉取最新代码,然后直接 git rebase feat/a 解决冲突后直接 git push 到远程仓库的 dev 分支。


有些人可能会有疑问:"直接 push 到这种环境分支没问题么,之前我们这种分支都是写保护的,只能接受 pr"。


确实,老的工作流对环境分支的保护都是比较严格的,但是这一套工作流没有这些限制,因为最遭的情况也就是你把 dev 分支搞崩了。那直接把远程 dev 分支删掉再从 master 或者 release 分支拉一个就完事了嘛,反正大家的功能都在各自的 feat 分支上。再极端一点,只要你不搞坏其他人的代码,你就算直接 git push --force 强制推送到 dev 分支都没问题。


3、同事 A 和 B 的新功能要基于同事 C 的新代码,这时候怎么办?


假设同事 C 开发的功能在 feat/c,那么同事 A 和 B 的分支就应该从 feat/c 创建并继续开发。而不是等同事 C 合并到 dev 之后再从 dev 创建。


4、既然是 feat 直接合并到指定分支,那么为什么最后一步不是 feat 分支合并到 master 分支呢?


因为这套流程里,最重要的就是保证 master 分支的稳定性。所以 master 分支上的代码必须是经过严格验证的。


并且如果 feat 直接合到 master 的话还会导致一些其他的问题:



  • 有一个同事比较粗心,在提交 pr 的时候本来该合到 dev 分支,结果一不小心点到了 master,审核的人有不注意直接点了同意,这时候 master 就被污染了。

  • 合并到 dev 时如果出现合并冲突的话,那么合并到 release 分支大概率也会再出现一遍,你总不会想合到 master 的时候去解决第三遍吧,而且也无法保证冲突的解决一定是不会出问题的。


所以说,最稳妥,最省心的做法就是直接把 release 分支的代码合并到 master。


5、hotfix master 怎么办?


git flow 里 hotfix 分支中的 commit 一方面要合并到 master,另一方面要同步到 dev。但是由于后续 dev 也要再次更新到 master,这个 hotfix 的 commit 就可能会导致困惑冲突。


但是这套新流程里就不会出现冲突,因为 dev 分支自己就已经是终点了,不会合并到其他分支。所以 hotfix 里的提交无论怎么合并到 dev,不管是 merge、rebase 还是 cherry-pick,都是可以的。甚至不用管也没关系,因为只要是新 feat 合并到 dev,这个 hotfix commit 就被自动携带过来了。


总结


其实这一套工作流其实是 gitlab flow + git flow 的一个调优,使其在保证效率的同时更贴近 git 新手的心理认知。总结一下就是 dev 分支并不会“晋升”到 release 分支。而是由 feat 分支发起到 release 分支的合并,同时 master 只接受来自 release 的合并,由此减少了很多需要遵守的规则和发生冲突的情况。


参考



作者:HOHO
来源:juejin.cn/post/7355845860683202595
收起阅读 »

Android 切换主题时如何恢复 Dialog?

我们都知道,Android 在横竖屏切换、主题切换、语言等操作时,系统会 finish Activity ,然后重建,这样便可以重新加载配置变更后的资源。 如果你只有 Activity 的内容需要展示,那这样处理是没有问题的,但是如果界面在点击操作之后打开一个...
继续阅读 »

我们都知道,Android 在横竖屏切换、主题切换、语言等操作时,系统会 finish Activity ,然后重建,这样便可以重新加载配置变更后的资源。


如果你只有 Activity 的内容需要展示,那这样处理是没有问题的,但是如果界面在点击操作之后打开一个 Dialog,那在配置改变后这个 Dialog 还会在么?答案是不一定,我们来看看展示 Dialog 有几种方式。


Dilog#show()


这可能是大家比较常用的方法,创建一个 Dialog ,然后调用其 show 方法,就像这样。


class MainActivity : AppCompatActivity() {  
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

findViewById<View>(R.id.tvDialog).setOnClickListener {
AlertDialog.Builder(this)
.setView(R.layout.test_dialog)
.show()
}
}
}

每次点击按钮会创建一个新的 Dialog 对象,然后调用 show 方法展示。我们来看看配置改变后,Dialog 的表现是怎样的。


video2.gif


通过视频我们可以看到,在切换横竖屏或主题时,Dialog 都没有恢复。这是因为Dialog#show这种方式是开发者自己管理 Dialog,所以在恢复 Activity 时,Activity 是不知道需要恢复 Dialog 的。那怎么让 Activity 知道当前展示了 Dialog 呢?那就需要用到下面的方式。


Activity#showDialog()


先来看看此方法的注释



Show a dialog managed by this activity. A call to onCreateDialog(int, Bundle) will be made with the same id the first time this is called for a given id. From thereafter, the dialog will be automatically saved and restored. If you are targeting Build.VERSION_CODES.HONEYCOMB or later, consider instead using a DialogFragment instead.
Each time a dialog is shown, onPrepareDialog(int, Dialog, Bundle) will be made to provide an opportunity to do any timely preparation.



简单来说这个方法会让 Activity 来管理需要展示的 Dialog,会跟 onCreateDialog(int, Bundle)成对出现,并且会保存这个 Dialog,在重复调用Activity#showDialog()时不会重复创建 Dialog 对象。Activity 自己管理 Dialog?那就能恢复了吗?我们来试试。


override fun onCreate(savedInstanceState: Bundle?) {  
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

findViewById<View>(R.id.tvDialog).setOnClickListener {
showDialog(100) //自定义 id
}
}

override fun onCreateDialog(id: Int): Dialog? {
if(id == 100){ // id 与 showDialog 匹配
return AlertDialog.Builder(this)
.setView(R.layout.test_dialog)
.create()
}
return super.onCreateDialog(id)
}

代码很简单,调用 Activity#showDialog(int id)方法,然后重写 Activity#onCreateDialog(id:Int),匹配两边的 id 就可以了。我们来看看效果。


video3.gif


我们可以看到,确实切换主题后 Dialog 是恢复了的,不过还有个问题,就是这个 ScrollView 的状态没有恢复,滑动的位置被还原了,难道我们需要手动记住滑动的 position 然后再恢复?是的,不过这个操作 Android 已经替我们做了,我们需要做的就是给需要恢复的组件指定一个 id 就行。


<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"  
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="200dp">


<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="300dp"
android:scrollbars="vertical"
android:scrollbarSize="10dp"
android:background="@color/primary_background">


<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">


<TextView
android:id="@+id/tvContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="@string/test_content"
android:textAlignment="center"
android:textSize="30sp"
android:textColor="@color/primary_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</FrameLayout>

刚刚 ScrollView 标签是没有 id 的,现在我们加了一个 id 再看看效果。


video4.gif


是不是很方便?这是什么原理呢?主要是两个方法,如下:


public void saveHierarchyState(SparseArray<Parcelable> container) {  
dispatchSaveInstanceState(container);
}

protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
Parcelable state = onSaveInstanceState();
if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
throw new IllegalStateException(
"Derived class did not call super.onSaveInstanceState()");
}
if (state != null) {
// Log.i("View", "Freezing #" + Integer.toHexString(mID)
// + ": " + state);
container.put(mID, state);
}
}
}

public void restoreHierarchyState(SparseArray<Parcelable> container) {
dispatchRestoreInstanceState(container);
}

protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
if (mID != NO_ID) {
Parcelable state = container.get(mID);
if (state != null) {
// Log.i("View", "Restoreing #" + Integer.toHexString(mID)
// + ": " + state);
mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
onRestoreInstanceState(state);
if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
throw new IllegalStateException(
"Derived class did not call super.onRestoreInstanceState()");
}
}
}
}


在 Actvity 执行 onSaveInstance 时,会保存 View 的层级状态,View 的 id 为 key,状态为 value,这样的一个SparseArray,View 的状态是在 View 的 onSaveInstance 方法生成的,所以,如果 View 没有重写 onSaveInstance时,就算指定了 id 也不会被恢复。我们来看看 ScrollView#onSaveInstance做了什么工作。


protected Parcelable onSaveInstanceState() {  
if (mContext.getApplicationInfo().targetSdkVersion <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
// Some old apps reused IDs in ways they shouldn't have.
// Don't break them, but they don't get scroll state restoration.
return super.onSaveInstanceState();
}
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.scrollPosition = mScrollY;
return ss;
}

ss.scrollPosition = mScrollY关键代码就是这一句,保存了 scrollPosition,恢复的逻辑就是在onRestoreInstance大家可以自己看看,逻辑比较简单,我这边就不列了。


Activity 如何恢复 Dialog?


配置变化后的恢复都会依赖onSaveInstanceonRestoreInstance,Dialog 也不例外,不过 Dialog 这两个流程都依赖 Activity,我们来完整过一遍 onSaveInstance 的流程,saveInstanceActivity#performSaveInstanceState开始.


Activity.java


/**  
* The hook for {@link ActivityThread} to save the state of this activity.
*
* Calls {@link #onSaveInstanceState(android.os.Bundle)}
* and {@link #saveManagedDialogs(android.os.Bundle)}.
*
* @param outState The bundle to save the state to.
*/

final void performSaveInstanceState(@NonNull Bundle outState) {
dispatchActivityPreSaveInstanceState(outState);
onSaveInstanceState(outState);
saveManagedDialogs(outState);
mActivityTransitionState.saveState(outState);
storeHasCurrentPermissionRequest(outState);
if (DEBUG_LIFECYCLE) Slog.v(TAG, "onSaveInstanceState " + this + ": " + outState);
dispatchActivityPostSaveInstanceState(outState);
}

/**
* Save the state of any managed dialogs.
*
* @param outState place to store the saved state.
*/

@UnsupportedAppUsage
private void saveManagedDialogs(Bundle outState) {
if (mManagedDialogs == null) {
return;
}
final int numDialogs = mManagedDialogs.size();
if (numDialogs == 0) {
return;
}
Bundle dialogState = new Bundle();
int[] ids = new int[mManagedDialogs.size()];
// save each dialog's bundle, gather the ids
for (int i = 0; i < numDialogs; i++) {
final int key = mManagedDialogs.keyAt(i);
ids[i] = key;
final ManagedDialog md = mManagedDialogs.valueAt(i);
dialogState.putBundle(savedDialogKeyFor(key), md.mDialog.onSaveInstanceState());
if (md.mArgs != null) {
dialogState.putBundle(savedDialogArgsKeyFor(key), md.mArgs);
}
}
dialogState.putIntArray(SAVED_DIALOG_IDS_KEY, ids);
outState.putBundle(SAVED_DIALOGS_TAG, dialogState);
}

saveManagedDialogs这个方法就是处理 Dialog 的流程,我们可以看到它会调用 md.mDialog.onSaveInstanceState(),来保存 Dialog 的状态,而这个md.mDialog就是在showDialog时保存的


public final boolean showDialog(int id, Bundle args) {  
if (mManagedDialogs == null) {
mManagedDialogs = new SparseArray<ManagedDialog>();
}
ManagedDialog md = mManagedDialogs.get(id);
if (md == null) {
md = new ManagedDialog();
md.mDialog = createDialog(id, null, args);
if (md.mDialog == null) {
return false;
}
mManagedDialogs.put(id, md);
}
md.mArgs = args;
onPrepareDialog(id, md.mDialog, args);
md.mDialog.show();
return true;
}

这样流程就能串起来了吧,用Activity#showDialog关联 Activity 与 Dialog,在 Activity onSaveInstance 时会调用 Dialog#onSaveInstance保存状态,而不管在 Activity 或 Dialog 的 onSaveInstance 里都会执行View#saveHierarchyState来保存视图层级状态,这样不管是 Activity 还是 Dialog 亦或是 View 便都可以恢复啦。


不过以上描述的恢复,恢复的都是 Android 原生数据,如果你需要恢复业务数据,那就需要自己保存啦,不过 Google 也为我们提供了解决方案,就是 Jetpack ViewModel,对吧?


这样通过 ViewModel 和 SaveInstance 就可以恢复所有业务和视图状态了!


总结


到这边,关于如何恢复 Dialog 的主要内容就分享完了,需要多说一句的是,Activity#showDialog方法已被标记为废弃。



Use the new DialogFragment class with FragmentManager instead; this is also available on older platforms through the Android compatibility package.



原理都是一样,大家可以根据自己的需要选择。


作者:PuddingSama
来源:juejin.cn/post/7246293244636004409
收起阅读 »

RecyclerView刷新后定位问题

问题描述做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳...
继续阅读 »

问题描述

做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳理清楚刷新后位置跳动的原因了。

原因分析

先简单描述下RecyclerView在notify后的过程:

  1. 根据是否是全量刷新来选择触发RecyclerView.RecyclerViewDataObserver的onChanged方法或onItemRangeXXX方法

onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout

  1. 进入dispatchLayout流程 这一步分为三个步骤:
  • dispatchLayoutStep1:处理adapter的更新、决定哪些view执行动画、保存view的信息
  • dispatchLayoutStep2:真正执行childView的layout操作
  • dispatchLayoutStep3:触发动画、保存状态、清理信息

需要注意的是,在onMeasure的过程中,如果传入的measureMode不是exactly,会去调用dispatchLayoutStep1和dispatchLayoutStep2从而取得真正需要的宽高。 所以在dispatchLayout会先判断是否需要重新执行dispatchLayoutStep1和dispatchLayoutStep2

重点分析dispatchLayoutStep2这一步: 核心操作在 mLayout.onLayoutChildren(mRecycler, mState)这一行。以LinearLayoutManager为例继续往下挖:

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
final View focused = getFocusedChild();
if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION
|| mPendingSavedState != null) {
mAnchorInfo.reset();
mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;
// 关键步骤1,寻找锚点View位置
updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
mAnchorInfo.mValid = true;
} else if (focused != null && (mOrientationHelper.getDecoratedStart(focused)
>= mOrientationHelper.getEndAfterPadding()
|| mOrientationHelper.getDecoratedEnd(focused)
<= mOrientationHelper.getStartAfterPadding())) {
mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
}
...
// fill towards end
updateLayoutStateToFillEnd(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForEnd;
//关键步骤2,从锚点View位置往后填充
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
final int lastElement = mLayoutState.mCurrentPosition;
if (mLayoutState.mAvailable > 0) {
//如果锚点位置后面数据不足,无法填满剩余的空间,那把剩余空间加到顶部
extraForStart += mLayoutState.mAvailable;
}
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForStart;
mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
//关键步骤3,从锚点View位置向前填充
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;

if (mLayoutState.mAvailable > 0) {
//如果锚点View位置前面数据不足,那把剩余空间加到尾部再做一次尝试
extraForEnd = mLayoutState.mAvailable;
// start could not consume all it should. add more items towards end
updateLayoutStateToFillEnd(lastElement, endOffset);
mLayoutState.mExtraFillSpace = extraForEnd;
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
}
}

先解释一下锚点View,锚点View在一次layout过程中的位置不会发生变化,即之前在哪里显示,这次layout完还在哪,从视觉上看没有位移。

总结一下,mLayout.onLayoutChildren主要做了以下几件事:

  1. 调用updateAnchorInfoForLayout方法确定锚点view位置
  2. 从锚点view后面的位置开始填充,直到后面空间被填满或者已经遍历到最后一个itemView
  3. 从锚点view前面的位置开始填充,直到空间被填满或者遍历到indexe为0的itemView
  4. 经过第三步后仍有剩余空间,则把剩余空间加到尾部再做一次尝试

所以回到一开始的问题,RecyclerView在notify之后位置跳跃的关键在于锚点View的确定,也就是updateAnchorInfoForLayout方法,所以下面重点看下这个方法:

private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
AnchorInfo anchorInfo)
{
if (updateAnchorFromPendingData(state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from pending information");
}
return;
}

if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from existing children");
}
return;
}
if (DEBUG) {
Log.d(TAG, "deciding anchor info for fresh state");
}
anchorInfo.assignCoordinateFromPadding();
anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
}

这个方法比较短,所以代码全贴出来了。如果是调用了scrollToPosition后的刷新,会通过updateAnchorFromPendingData方法确定锚点View位置,否则调用updateAnchorFromChildren来计算:

private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler,
RecyclerView.State state, AnchorInfo anchorInfo) {
if (getChildCount() == 0) {
return false;
}
final View focused = getFocusedChild();
if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
anchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
return true;
}
if (mLastStackFromEnd != mStackFromEnd) {
return false;
}
View referenceChild =
findReferenceChild(
recycler,
state,
anchorInfo.mLayoutFromEnd,
mStackFromEnd);
if (referenceChild != null) {
anchorInfo.assignFromView(referenceChild, getPosition(referenceChild));
...
return true;
}
return false;
}

代码比较简单,如果有焦点View,并且焦点View没被remove,则使用焦点View作为锚点。否则调用findReferenceChild来查找:

View findReferenceChild(RecyclerView.Recycler recycler, RecyclerView.State state,
boolean layoutFromEnd, boolean traverseChildrenInReverseOrder)
{
ensureLayoutState();

// Determine which direction through the view children we are going iterate.
int start = 0;
int end = getChildCount();
int diff = 1;
if (traverseChildrenInReverseOrder) {
start = getChildCount() - 1;
end = -1;
diff = -1;
}

int itemCount = state.getItemCount();

final int boundsStart = mOrientationHelper.getStartAfterPadding();
final int boundsEnd = mOrientationHelper.getEndAfterPadding();

View invalidMatch = null;
View bestFirstFind = null;
View bestSecondFind = null;

for (int i = start; i != end; i += diff) {
final View view = getChildAt(i);
final int position = getPosition(view);
final int childStart = mOrientationHelper.getDecoratedStart(view);
final int childEnd = mOrientationHelper.getDecoratedEnd(view);
if (position >= 0 && position < itemCount) {
if (((RecyclerView.LayoutParams) view.getLayoutParams()).isItemRemoved()) {
if (invalidMatch == null) {
invalidMatch = view; // removed item, least preferred
}
} else {
// b/148869110: usually if childStart >= boundsEnd the child is out of
// bounds, except if the child is 0 pixels!
boolean outOfBoundsBefore = childEnd <= boundsStart && childStart < boundsStart;
boolean outOfBoundsAfter = childStart >= boundsEnd && childEnd > boundsEnd;
if (outOfBoundsBefore || outOfBoundsAfter) {
// The item is out of bounds.
// We want to find the items closest to the in bounds items and because we
// are always going through the items linearly, the 2 items we want are the
// last out of bounds item on the side we start searching on, and the first
// out of bounds item on the side we are ending on. The side that we are
// ending on ultimately takes priority because we want items later in the
// layout to move forward if no in bounds anchors are found.
if (layoutFromEnd) {
if (outOfBoundsAfter) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
} else {
if (outOfBoundsBefore) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
}
} else {
// We found an in bounds item, greedily return it.
return view;
}
}
}
}
// We didn't find an in bounds item so we will settle for an item in this order:
// 1. bestSecondFind
// 2. bestFirstFind
// 3. invalidMatch
return bestSecondFind != null ? bestSecondFind :
(bestFirstFind != null ? bestFirstFind : invalidMatch);
}

解释一下,查找过程会遍历RecyclerView当前可见的所有childView,找到第一个没被notifyRemove的childView就停止查找,否则会把遍历过程中找到的第一个被notifyRemove的childView作为锚点View返回。

这里需要注意final int position = getPosition(view);这一行代码,getPosition返回的是经过校正的最终position,如果ViewHolder被notifyRemove了,这里的position会是0,所以如果可见的childView都被remove了,那最终定位的锚点View是第一个childView,锚点的position是0,偏移量offset是这个被删除的childView的top值,这就会导致后面fill操作时从位置0开始填充,先把position=0的view填充到偏移量offset的位置,再往后依次填满剩余空间,这也是导致画面上的跳动的根本原因。


作者:Ernest912
来源:juejin.cn/post/7259358063517515834

收起阅读 »

降本增笑,领导要求程序员半年做出一个金蝶

关于降本增效的事,今天在网上看到一个帖子,虽然有点搞笑,但是对于打工人(特别是技术同学)来说,可挖掘的东西也不少,特别分享给大家。 真的是好多年没听说这样的事了,记得以前总有老板让做个淘宝、做个京东,然后预算只有几千块,最多几万块。 这些故事程序员谈起来往往...
继续阅读 »

关于降本增效的事,今天在网上看到一个帖子,虽然有点搞笑,但是对于打工人(特别是技术同学)来说,可挖掘的东西也不少,特别分享给大家。



真的是好多年没听说这样的事了,记得以前总有老板让做个淘宝、做个京东,然后预算只有几千块,最多几万块。


这些故事程序员谈起来往往都是哈哈一笑,并疯狂吐槽一番。



不过笑过之后,大家是否想过如何去解决问题?或者真的去评估下可行性,探索一下可能的实现路径。


找到问题


首先我们看下老板的问题。老板的根本问题并不是想要做金蝶,为什么这么说呢?


我们看看网友的描述就知道了:经济下行,领导不想出金蝶系统的维护费,不想为新功能花大价钱。这才是根本问题,用四个字来说就是:降低成本。


然后才是老板想到能不能用更少的钱达到金蝶系统的使用效果,再之后才是自己能不能做一个类似金蝶的系统,并思考了自己可以承担的成本:一个前端、一个后端,半年时间。


最后问题被抛到了这位网友的手里。可以看得出来这位网友也不太懂,还去咨询了朋友。不知道它有没有向朋友说清楚问题,还是只说了老板想自己做一个金蝶系统,结果是朋友们都说不可行。


遇到问题时,我们得把这个问题完完整整的捋一遍,找到最根本的问题,然后再说怎么解决问题,否则只是停留在表面,就可能事倍功半。在这个上下文中,根本的问题就是:降低成本。



解决问题


明确了老板的根本问题,我们就可以琢磨方案了。既然是降低财务系统的成本,可行的方案应该还是有几个的。


使用替代产品


假如公司只使用产品的部分功能,是不是可以选择金蝶的低版本?是不是可以降低一些人头费?


金蝶的服务贵,是不是可以选择一些小厂的产品?国内做财务系统的应该挺多的,小厂也更容易妥协。


或者选择SaaS服务,虽然SaaS用久了成本也不低,但是可以先撑过这几年,降低当前的支出。


当然替换财务系统也是有成本的,需要仔细评估。不过既然都想自己做了,这个成本应该能hold住。


找第三方维护


金蝶的服务贵,是不是可以找其它三方或者个人来维护修改?根据我之前的了解,金蝶这种公司有很多的实施工作是外包出去的,或者通过代理商来为客户服务,能不能找这些服务商来代替金蝶呢?或者去某些副业平台上应该也能找到熟悉金蝶系统的人。


当然这个还要看系统能不能顺利交接,金蝶有没有什么软硬件限制,第三方能不能接过来。


另外最重要的必须考虑系统和数据的安全性,不能因小失大。


自己开发


虽然自己开发的困难和成本都很高,但我仍旧认为这可能也是一个合适的解决方案。理由有下面两点。



  • 功能简单:如果公司的业务比较简单,使用的流程也简单,比如不使用涉及复杂的财务处理,那么捋一捋,能给开发人员讲清楚,也是有可能在短时间内完成的。

  • 迭代渐进:长城不是一天建成的,系统也都是逐渐迭代完善的。自己开发可以先从部分模块或者功能开始,然后逐步替换,比如前边的流程先在新系统中做,最后再导入金蝶。即使不能做到逐步替换,也可以控制系统的风险,发现搞不定时,及时止损。相信老板也能明白这个道理,如果不明白或者不接受,那确实搞不了。



当然我们也肯定不能忽视这其中的困难。我之前做过和金蝶系统的对接,订单的收付款在业务系统完成,然后业务系统生成凭证导入到金蝶K3。依稀记得业务也不算复杂,但是需求分析做了好几遍,我的代码也是改了又改,上线之后遇到各种问题,继续改,最终花了几个月才稳定下来。


事后分析原因,大概有这么几点:



  • 产品或者需求分析人员没接触过类似的业务,即使他对财务系统有一些经验,也不能准确的将客户的业务处理方式转换到产品设计中;

  • 财务人员说不明白,虽然他会使用金蝶系统,但是他不能一次性的把所有规则都讲出来,讲出来也很难让程序员在短时间内理解;

  • 程序员没做过财务系统,没接触过类似的业务,系统的设计可能要反复调整,比如业务模块的划分逻辑,金额用Long还是用BigDecimal,数据保留几位小数,这都会大幅延长开发周期,如果不及时调整就可能写成一锅粥,后期维护更困难。


这还只是和金蝶系统做一个简单的对接,如果要替代它,还要实现更多的功能,总结下,企业可能会面对下面这些困难:


业务复杂:财务规则一般都比较复杂,涉及到各种运算,各种数字、报表能把人搞晕。如果公司的业务也很复杂,比如有很多分支或者特殊情况,软件开发的难度也会很大,这种难度的变化不是线性增加的,很可能是指数级增长的,一个工作流的设计可能就把人搞死了。


懂业务的人:系统过于复杂时,可能没有一个人能把系统前前后后、左左右右的整明白。而要完成这样一个复杂的系统,必须有人能从高层次的抽象,到具体数字运算的细枝末节,完完全全的整理出来,逻辑自洽,不重不漏,并形成文档,还要能把程序员讲明白。


懂架构的人:这里说的是要有一个经验丰富的程序员,不能是普通的码农,最好是有财务系统开发经验的架构师。没走过的路,总是要踩坑的。有经验的开发人员可以少走很多弯路,极大降低系统的风险。这样的人才如果公司没有,外招的难度比较大,即使能找到,成本也不低。


灵活性问题:开发固定业务流程的系统一般不会太考虑灵活性的问题,如果业务需要调整,可能需要对系统进行大幅修改,甚至推倒重来。如果要让系统灵活些,必然对业务和技术人员都提出了更高的要求,也代表着更强的工作能力和更多的工作量。


和其它系统的对接:要不要和税务系统对接?要不要和客户管理系统对接?要不要和公司的OA对接?每一次对接都要反复调试,工作量肯定下不来。


总之,稍微涉及到财务处理的系统,都不是一个前端和一个后端能在短时间内完全搞出来的。


对程序开发的启示


搞清楚需求


日常开发过程中,大家应该都遇到过不少此类问题。领导说这里要加个功能,然后产品和开发就去吭哧吭哧做了,做完了给领导一看,不是想要的,然后返工反复修改。或者说用户提了一个需求,产品感觉自己懂了,然后就让开发这么做那样改,最后给用户一看,什么破玩意。这都是没有搞清楚真正的需求,没有触达那个根本问题。


虽然开发人员可以把这些问题全部甩给产品,自己只管实现,但这毕竟实实在在的消耗了程序员的时间,大量的时间成本和机会成本,去干点有意义的事情不好吗?所以为了不浪费时间,开发也要完整的了解用户需求。在一个团队中,至少影响产品落地的关键开发人员要搞懂用户的需求。


那么遇到这种问题,程序员是不是可以直接跑路呢?


也是一个选择, 不过对于一个有追求的程序员,肯定也是想把程序设计好、架构好的,能解决实际问题的,这也需要对用户需求的良好把控能力,比如我们要识别出哪些是系统的核心模块,哪些是可扩展能力,就像设计冯诺依曼计算机,你设计的时候会怎么处理CPU和输入输出设备之间的关系呢?


对于用户需求,产品想的是怎么从流程设计上去解决,开发需要考虑的是怎么从技术实现上去满足,两者相向而行,才能把系统做好。


当然准确把握用户的需求,很多时候并不是我说的这么容易,因为用户可能也说不清楚,我们可能需要不断的追问才能得到一些关键信息。比如这位网友去咨询朋友时,可能需求就变成了:我们要做一个财务系统,朋友如果不多问,也只能拿到这个需求,说不定这位朋友也有二次开发的能力,错失了一次挣钱的好机会。还有这位老板上边可能还有更大的老板,这位老板降低成本的需求也可能是想在大老板面前表现一下,那是不是还有其它降本增效的方法呢?比如简化流程、裁掉几个不关键的岗位(这个要得罪人了)。


我们要让程序始终保持在良好的状态,就要准确的把握用户需求,要搞懂用户需求,就需要保持谦逊求知的心态,充分理解用户的问题,这种能力不是朝夕之间就可以掌握的,是需要修炼的。


动起来


任何没有被满足的需求都是一次机会。


我经常会在技术社区看到一些同学分享自己业余时间做的独立产品,有做进销存的、客户管理的、在线客服的,还有解决问题的各种小工具,而且有的同学还挣到了钱。


我并不是想说让大家都去搞钱,而是说要善于发现问题、找到机会,然后动起来、去实践,实践的过程中我们可以发现更多的问题,然后持续解决问题,必然能让自己变得越来越强。在经济不太好的情况下,我们才有更强的生存能力。




啰里八嗦一大堆,希望能对你有所启发。




作者:萤火架构
来源:juejin.cn/post/7317704464999235593
收起阅读 »

用 VitePress 搭建电子书,绝了!

web
大家好,我是杨成功。 自从《前端开发实战派》出版以后,好多买过的小伙伴都联系我,问我有没有电子书?纸质书在公司看不方便,一些现成的代码没办法复制。 确实没有电子版,我也听大家的建议上微信读书,结果那边审核没通过。我想不行我自己搞一个电子书呗,给买了纸书的朋友免...
继续阅读 »

大家好,我是杨成功。


自从《前端开发实战派》出版以后,好多买过的小伙伴都联系我,问我有没有电子书?纸质书在公司看不方便,一些现成的代码没办法复制。


确实没有电子版,我也听大家的建议上微信读书,结果那边审核没通过。我想不行我自己搞一个电子书呗,给买了纸书的朋友免费阅读,方便他们随时查阅。


经过一番调研,VitePress 的 UI 我最喜欢,扩展性也非常好,所以就用它来搭建。


新建项目


在一个空文件夹下,使用命令生成项目:


$ npx vitepress init

全部使用默认选项,生成结构如下:


2024-04-07-16-55-42.png


图中的 .vitepress/config.mts 就是 VitePress 的配置文件。另外三个 .md 文件是 Markdown 内容,VitePress 会根据文件名自动生成路由,并将文件内容转换为 HTML 页面。


为了代码更优雅,一般会把 Markdown 文件放在 docs 目录下。只需要添加一个配置:


// config.mts
export default defineConfig({
srcDir: 'docs',
});

改造后的目录结构是这样:


2024-04-07-17-27-23.png


安装依赖并运行项目:


$ yarn add vitepress vue
$ yarn run docs:dev

前期设计的难点


电子书的内容不完全对外开放,只有买过纸书的人才能阅读。和掘金小册差不多,只能看部分内容,登录或购买后才能解锁全部章节。


而 VitePress 是一个静态站点生成器,默认只解析 Markdown。要想实现上述的功能,必须用到纯 Vue 组件,这需要通过扩展默认主题来实现。


扩展默认主题,也就是扩展 VitePress 的原始 Vue 组件,达到自定义的效果。


遵循这个思路,我们需要扩展的内容如下:



  • 添加登录页面,允许用户登录。

  • 添加用户中心页面,展示用户信息、退出登录。

  • 修改头部组件,展示登录入口。

  • 页面根组件,获取当前用户状态。

  • 修改内容组件,无权限时不展示内容。


当然了还需要接入几个接口:



  • 登录/注册接口。

  • 获取当前用户信息接口。

  • 验证当前用户权限的接口。


扩展默认主题


扩展默认主题,首先要创建一个 .vitepress/theme 文件夹,用来存放主题的组件、样式等代码。该文件夹下新建 index.ts 表示主题入口文件。


入口文件导出主题配置:


// index.ts
import Layout from './Layout.vue';

export default {
Layout,
enhanceApp({ app, router, siteData }) {
// ...
},
};

上面代码导入了一个 Layout.vue,这个组件是自定义布局组件:


<!-- Layout.vue -->
<script setup>
import DefaultTheme from 'vitepress/theme';

const { Layout } = DefaultTheme;
</script>

<template>
<Layout>
<template #nav-bar-content-after>
<button>登录</button>
</template>
</Layout>
</template>

为啥需要这个组件呢?因为该组件是项目根组件,可以从两个方面扩展:


(1)使用自定义插槽。


Layout 组件提供了许多插槽,允许我们在页面的多处位置插入内容。比如上面代码中的 nav-bar-content-after 插槽,会在头部组件右侧插入登录按钮。


具体有哪些插槽,详见这里


(2)做全局初始化。


当刷新页面时,需要做一些初始化操作,比如调用接口、监听某些状态等。


这个时候可以使用 Vue 的各种钩子函数,比如 onMounted:


// Layout.vue
<script setup>
import { onMounted } from 'vue';
onMounted(() => {
console.log('初始化、请求接口');
});
</script>

如何定制内容组件?


VitePress 的内容组件,会把所有 Markdown 内容渲染出来。但是如果用户没有登录,我们不允许展示内容,而是提示用户登录,就像掘金小册这样:


2024-04-07-08-50-00.png


定制内容组件,核心是在内容渲染的区域加一个判断:如果用户登录且验证通过,渲染内容即可;否则,展示类似上图的提示登录界面。


接下来我翻了 VitePress 的源码,找到了这个名为 VPDoc.vue 的组件:



github.com/vuejs/vitep…



在上方组件大概 46 行,我找到了内容渲染区域:


2024-04-07-09-09-20.png


就在这个位置,添加一个判断,就达到我们想要的效果了:


<main class="main">
<Content
class="vp-doc"
v-if="isLogin"
:class="[
pageName,
theme.externalLinkIcon && 'external-link-icon-enabled'
]"

/>

<div v-else>
<h4>登录后阅读全文</h4>
<button>去登录</button>
</div>

</main>

那怎么让这个修改生效呢?


VitePress 提供了一个 重写内部组件 的方案。将 VPDoc.vue 组件拷贝到本地,按照上述方法修改,重命名为 CusVPDoc.vue


在配置文件 .vitepress/config.ts 中添加重写逻辑:


// config.ts
export default defineConfig({
vite: {
resolve: {
alias: [
{
find: /^.*\/VPDoc\.vue$/,
replacement: fileURLToPath(new URL('./components/CusVPDoc.vue', import.meta.url)),
},
],
},
},
});

这样便实现了自定义内容组件,电子书截图如下:


2024-04-10-09-28-42.png


添加自定义页面


添加自定义页面,首先要创建一个自定义组件。


以登录页面为例,创建一个自定义组件 CusLogin.vue,编写登录页面和逻辑,然后将其注册为一个全局组件。在 Markdown 页面文件中,直接使用这个组件。


注册全局组件的方法,是在主题入口文件中添加以下配置:


// .vitepress/theme/index.ts
import CusLogin from './components/CusLogin.vue'

export default {
...
enhanceApp({ app}) {
app.component("CusLogin", CusLogin); // 注册全局组件
// ...
},
} satisfies Theme;

最后,新建 Markdown 文件 login.md,写入内容如下:


---
layout: page
---


<CusLogin />

现在访问路由 “/login” 就可以看到自定义登录页面了。


2024-04-10-09-30-28.png


全局状态管理


涉及到用户登录,那么必然会涉及在多个组件中共享登录信息。


如果要做完全的状态管理,不用说,安装 Pinia 并经过一系列配置,可以实现。但是我们的需求只是共享登录信息,完全没必要再装一套 Pinia,使用 组合式函数 就可以了。


具体怎么实现,在另一篇文章 Vue3 新项目,没必要再用 Pinia 了! 中有详细介绍。


接入 Bootstrap


自定义页面,总是需要一个 UI 框架。上面的登录页面中,我使用了 Bootstrap。


Vitepress 使用 UI 框架有一个限制:必须兼容 SSR。因为 Vitepress 本质上使用了 Vue 的服务端渲染功能,在构建期间生成多个 HTML 页面,并不是常见的单页面应用。


这意味着,Vue 组件只有在 beforeMountmounted 钩子中才能访问 DOM API。


而 Bootstrap 不需要打包构建就可以使用 UI,非常适合 Vitepress。


首先安装 Bootstrap:


$ yarn add bootstrap

然后在主题入口文件中引入 Sass 和 JS 文件:


import 'bootstrap/scss/bootstrap-cus.scss';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';

按常理说,这样就可以了,但是实际运行会报错:找不到某个 DOM API。


还记得那个限制吗?必须兼容 SSR!因此不能直接引入 JS 文件。


解决方法是在自定义布局组件 Layout.vue 中通过异步的方式引入:


// .vitepress/theme/Layout.vue
onMounted(() => {
import('bootstrap/dist/js/bootstrap.bundle.min.js');
});

这样就大功告成了,你可以使用 Bootstrap 中丰富的 UI。


最终的电子书效果:《前端开发实战派》,欢迎点评。


最后留一个思考题:Vitepress 支持主题切换,Bootstrap 也分浅色和深色主题;切换 Vitepress 主题时,如何同步更改 Bootstrap 的主题呢?



公众号:程序员成功

作者微信:杨成功



作者:杨成功
来源:juejin.cn/post/7355759709167910923
收起阅读 »

为了NullPointerException,你知道Java到底做了多少努力吗?

null 何错之有? 对于 Java 程序员而言,NullPointerException 是最令我们头疼的异常,没有之一 ,大明哥相信到这篇文章为止一定还有不少人在写下面这段代码: if (obj != null) { //... } NullPo...
继续阅读 »

null 何错之有?


对于 Java 程序员而言,NullPointerException 是最令我们头疼的异常,没有之一 ,大明哥相信到这篇文章为止一定还有不少人在写下面这段代码:


if (obj != null) {
//...
}


NullPointerException 是 Java 1.0 版本引入的,引入它的主要目的是为了提供一种机制来处理 Java 程序中的空引用错误。空引用(Null Reference)是一个与空指针类似的概念,是一个已宣告但其并未引用到一个有效对象的变量。它是伟大的计算机科学家Tony Hoare 早在1965年发明的,最初作为编程语言ALGOL W的一部分。嗯,就是这位老爷子




1965年,老爷子 Tony Hoare 在设计ALGOL W语言时,为了简化ALGOL W 的设计,引入空引用的概念,他认为空引用可以方便地表示“无值”或“未知值”,其设计初衷就是要“通过编译器的自动检测机制,确保所有使用引用的地方都是绝对安全的”。但是在2009年,很多年后,他开始为自己曾经做过这样的决定而后悔不已,把它称为“一个价值十亿美元的错误”。实际上,Hoare的这段话低估了过去五十年来数百万程序员为修复空引用所耗费的代价。因为在ALGOL W之后出现的大多数现代程序设计语言,包括Java,都采用了同样的设计方式,其原因是为了与更老的语言保持兼容,或者就像Hoare曾经陈述的那样,“仅仅是因为这样实现起来更加容易”。


在 Java 中,null 会带来各种问题(摘自:《Java 8 实战》):



  • 它是错误之源。 NullPointerException 是目前Java程序开发中最典型的异常。它会使你的代码膨胀。

  • 它让你的代码充斥着深度嵌套的null检查,代码的可读性糟糕透顶。

  • 它自身是毫无意义的。 null自身没有任何的语义,尤其是是它代表的是在静态类型语言中以一种错误的方式对缺失变量值的建模。

  • 它破坏了Java的哲学。 Java一直试图避免让程序员意识到指针的存在,唯一的例外是:null指针。

  • 它在Java的类型系统上开了个口子。 null并不属于任何类型,这意味着它可以被赋值给任意引用类型的变量。这会导致问题, 原因是当这个变量被传递到系统中的另一个部分后,你将无法获知这个null变量最初赋值到底是什么类型。


Java 做了哪些努力?


Java 为了处理 NullPointerException 一直在努力着。



  • Java 8 引入 Optional:减少 null而引发的NullPointerException异常

  • Java 14 引入 Helpful NullPointerExceptions:帮助我们更好地排查 NullPointerException


Java 8 的 Optional


Optional 是什么


Optional 是 Java 8 提供了一个类库。被设计出来的目的是为了减少因为null而引发的NullPointerException异常,并提供更安全和优雅的处理方式。


Java 中臭名昭著的 NullPointerException 是导致 Java 应用程序失败最常见的原因,没有之一,大明哥认为没有一个 Java 开发程序员没有遇到这个异常。为了解决 NullPointerException,Google Guava 引入了 Optional 类,它提供了一种在处理可能为null值时更灵活和优雅的方式,受 Google Guava 的影响,Java 8 引入 Optional 来处理 null 值。


在 Javadoc 中是这样描述它的:一个可以为 null 的容器对象。所以 java.util.Optional 是一个容器类,它可以保存类型为 T 的值,T 可以是实际 Java 对象,也可以是 null


Optional API 介绍


我们先看 Optional 的定义:


public final class Optional {

/**
* 如果非空,则为该值;如果为空,则表示没有值存在。
*/

private final T value;

//...
}

从这里可以看出,Optional 的本质就是内部存储了一个真实的值 T,如果 T 非空,就为该值,如果为空,则表示该值不存在。


构造 Optional 对象


Optional 的构造函数是 private 权限的,它对外提供了三个方法用于构造 Optional 对象。



Optional.of(T value)



    public static  Optional<T> of(T value) {
return new Optional<>(value);
}

private Optional(T value) {
this.value = Objects.requireNonNull(value);
}

所以 Optional.of(T value) 是创建一个包含非null值的 Optional 对象。如果传入的值为null,将抛出NullPointerException 异常信息。



Optional.ofNullable(T value)



    public static  Optional ofNullable(T value) {
return value == null ? empty() : of(value);
}

创建一个包含可能为null值的Optional对象。如果传入的值为null,则会创建一个空的Optional对象。



Optional.empty()



    public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}

private static final Optional EMPTY = new Optional<>();

创建一个空的Optional对象,表示没有值。


检查是否有值


Optional 提供了两个方法用来检查是否有值。



isPresent()



isPresent() 用于检查Optional对象是否包含一个非null值,源码如下:


    public boolean isPresent() {
return value != null;
}

示例如下:


User user = null;
Optional optional = Optional.ofNullable(user);
System.out.println(optional.isPresent());
// 结果......
false


ifPresent(Consumer action)



该方法用来执行一个操作,该操作只有在 Optional 包含非null值时才会执行。源码如下:


    public void ifPresent(Consumersuper T> consumer) {
if (value != null)
consumer.accept(value);
}

需要注意的是,这是 Consumer,是没有返回值的。


示例如下:


User user = new User("xiaoming");
Optional.ofNullable(user).ifPresent(value-> System.out.println("名字是:" + value.getName()));

获取值


获取值是 Optional 中的核心 API,Optional 为该功能提供了四个方法。



get()



get() 用来获取 Optional 对象中的值。如果 Optional 对象的值为空,会抛出NoSuchElementException异常。源码如下:


    public T get() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}


orElse(T other)



orElse() 用来获取 Optional 对象中的值,如果值为空,则返回指定的默认值。源码如下:


    public T orElse(T other) {
return value != null ? value : other;
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElse(new User("xiaohong"));
System.out.println(user);
// 结果......
User(name=xiaohong, address=null)


orElseGet(Supplier other)



orElseGet()用来获取 Optional 对象中的值,如果值为空,则通过 Supplier 提供的逻辑来生成默认值。源码如下:


    public T orElseGet(Supplierextends T> other) {
return value != null ? value : other.get();
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElseGet(() -> {
Address address = new Address("湖南省","长沙市","岳麓区");
return new User("xiaohong",address);
});
System.out.println(user);
// 结果......
User(name=xiaohong, address=Address(province=湖南省, city=长沙市, area=岳麓区))

orElseGet()orElse()的区别是:当 T 不为 null 的时候,orElse() 依然执行 other 的部分代码,而 orElseGet() 不会,验证如下:


public class OptionalTest {

public static void main(String[] args) {
User user = new User("xiaoming");
User user1 = Optional.ofNullable(user).orElse(createUser());
System.out.println(user);

System.out.println("=========================");

User user2 = Optional.ofNullable(user).orElseGet(() -> createUser());
System.out.println(user2);
}

public static User createUser() {
System.out.println("执行了 createUser() 方法");
Address address = new Address("湖南省","长沙市","岳麓区");
return new User("xiaohong",address);
}
}

执行结果如下:



是不是 orElse() 执行了 createUser() ,而 orElseGet() 没有执行?一般而言,orElseGet()orElse() 会更加灵活些。



orElseThrow(Supplier exceptionSupplier)



orElseThrow() 用来获取 Optional 对象中的值,如果值为空,则通过 Supplier 提供的逻辑抛出异常。源码如下:


    public extends Throwable> T orElseThrow(Supplier exceptionSupplier) throws X {
if (value != null) {
return value;
} else {
throw exceptionSupplier.get();
}
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElseThrow(() -> new RuntimeException("用户不存在"));

类型转换


Optional 提供 map()flatMap() 用来进行类型转换。



map(Function mapper)



map() 允许我们对 Optional 对象中的值进行转换,并将结果包装在一个新的 Optional 对象中。该方法接受一个 Function 函数,该函数将当前 Optional 对象中的值映射成另一种类型的值,并返回一个新的 Optional 对应,这个新的 Optional 对象中的值就是映射后的值。如果当前 Optional 对象的值为空,则返回一个空的 Optional 对象,且 Function 不会执行,源码如下:


    public Optional map(Functionsuper T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Optional.ofNullable(mapper.apply(value));
}
}

比如我们要获取 User 对象中的 name,如下:


User user = new User("xiaolan");
String name = Optional.ofNullable(user).map(value -> value.getName()).get();
System.out.println(name);
// 结果......
xiaolan


Function> mapper



flatMap()map() 相似,不同之处在于 flatMap()的映射函数返回的是一个 Optional 对象而不是直接的值,它是将当前 Optional 对象映射为另外一个 Optional 对象。


    public<U> Optional<U> flatMap(Functionsuper T, Optional<U>> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Objects.requireNonNull(mapper.apply(value));
}
}

上面获取 name 的代码如下:


String name = Optional.ofNullable(user).flatMap(value -> Optional.ofNullable(value.getName())).get();

flatMap() 内部需要再次封装一个 Optional 对象,所以 flatMap() 通常用于在一系列操作中处理嵌套的Optional对象,以避免层层嵌套的情况,使代码更加清晰和简洁。


过滤


Optional 提供了 filter() 用于在 Optional 对象中的值满足特定条件时进行过滤操作,源码如下:


    public Optional filter(Predicatesuper T> predicate) {
Objects.requireNonNull(predicate);
if (!isPresent())
return this;
else
return predicate.test(value) ? this : empty();
}

filter() 接受 一个Predicate 来对 Optional 中包含的值进行过滤,如果满足条件,那么还是返回这个 Optional;否则返回 Optional.empty


实战应用


这里大明哥利用 Optional 的 API 举几个例子。



  • 示例一


Java 8 以前:


    public static String getUserCity(User user) {
if (user != null) {
Address address = user.getAddress();
if (address != null) {
return address.getCity();
}
}
return null;
}

常规点的,笨点的方法:


    public static String getUserCity(User user) {
Optional userOptional = Optional.of(user);
return Optional.of(userOptional.get().getAddress()).get().getCity();
}

高级一点的:


    public static String getUserCity(User user) {
return Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.orElseThrow(() -> new RuntimeException("值不存在"));
}

是不是比上面高级多了?



  • 示例二


比如我们要获取末尾为"ming"的用户的 city,不是的统一返回 "深圳市"。


Java 8 以前


    public static String getUserCity(User user) {
if (user != null && user.getName() != null) {
if (user.getName().endsWith("ming")) {
Address address = user.getAddress();
if (address != null) {
return address.getCity();
} else {
return "深圳市";
}
} else {
return "深圳市";
}
}

return "深圳市";
}

Java 8


    public static String getUserCity2(User user) {
return Optional.ofNullable(user)
.filter(u -> u.getName().endsWith("ming"))
.map(User::getAddress)
.map(Address::getCity)
.orElse("深圳市1");
}

这种写法确实是优雅了很多。其余的例子大明哥就不一一举例了,这个也没有其他技巧,唯手熟尔!!


Java 14 的 Helpful NullPointerExceptions


我们先看如下报错信息:


Exception in thread "main" java.lang.NullPointerException
at com.skjava.java.feature.Test.main(Test.java:6)

从这段报错信息中你能看出什么? Test.java 中的第 6 行产生了 NullPointerException。还能看出其他什么吗?如果这段报错的代码是这样的:


public class Test {
public static void main(String[] args) {
User user = new User();
System.out.println(user.getAddress().getProvince().length());
}
}

你知道是哪里报空指针吗? 是user.getAddress() 还是 user.getAddress().getProvince() ?看不出来吧?从这个报错信息中,我们确实很难搞清楚具体是谁导致的 NullPointerException


在 Java 14 之前,当发生 NullPointerException 时,错误信息通常很简单,仅仅只指出了出错的行号。这会导致我们在排查复杂表达式时显得比较困难,因为无法确定是表达式中的哪一部分导致了 NullPointerException,我们需要花费额外的时间进行调试,特别是在长链式调用或者包含多个可能为空的对象的情况下。


为了解决这个问题,Java 14 对 NullPointerException 的提示信息进行了改进,当发生 NullPointerException 时,异常信息会明确指出哪个具体的变量或者表达式部分是空的。例如,对于表达式 a.b().c().d(), 如果 b() 返回的对象是 null,异常信息将明确指出 b() 返回的对象为 null。例如上面的信息:


Exception in thread "main" java.lang.NullPointerException: Cannot invoke "*****.Address.getProvince()" because the return value of "*****.User.getAddress()" is null
at com.skjava.java.feature.Test.main(Test.java:6)

他会明确告诉你 User.getAddress() 返回的对象为 null


这样的提示信息将会让我们能够快速准确地定位导致 NullPointerException 的具体原因,无需逐步调试或猜测,有助于快速修复问题,减少维护时间和成本。


作者:大明哥_
来源:juejin.cn/post/7315080231627194387
收起阅读 »

HTML问题:如何实现分享URL预览?

web
前端功能问题系列文章,点击上方合集↑ 序言 大家好,我是大澈! 本文约2100+字,整篇阅读大约需要3分钟。 本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。 感谢关注微信公众号...
继续阅读 »

前端功能问题系列文章,点击上方合集↑


序言


大家好,我是大澈!


本文约2100+字,整篇阅读大约需要3分钟。


本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。


感谢关注微信公众号:“程序员大澈”,然后加入问答群,从此让解决问题的你不再孤单!


1. 需求分析


为了提高用户对页面链接分享的体验,需要对分享链接做一些处理。


以 Telegram(国外某一通讯软件) 为例,当在 Telegram 上分享已做过处理的链接时,它会自动尝试获取链接的预览信息,包括标题、描述和图片。


如此当接收者看到时,可以立即获取到分享链接的一些重要信息。这有助于接收者更好地了解链接的内容,决定是否点击查看详细内容。


图片


2. 实现步骤


2.1 实现前的说明


对于URL分享预览这个功能问题,在项目中挺常用的,只不过今天我们是以一些框架分享API的底层原理角度来讲的。


实现这种功能的关键,是在分享的链接中嵌入适当的元数据信息,应用软件会自动解析,请求分享链接的预览信息,并根据返回的元数据生成预览卡片。


对于国内的应用软件,目前我试过抖音,它可以实现分享和复制粘贴都自动解析,而微信、QQ等只能实现分享的自动解析。


对于国外的应用软件,我只实验过Telegram,它可以实现分享和复制粘贴都自动解析,但我想FacebookTwitterInstagram这些应用应该也都是可以的。


2.2 实现代码


实现URL链接的分享预览,你可以使用 Open Graph协议或 Twitter Cards,然后在 HTML 的 标签中,添加以下 meta 标签来定义链接预览的信息。


使用时,将所有meta全部复制过去,然后根据需求进行自定义即可。


还要注意两点,确保你页面的服务器正确配置了 SSL 证书,以及确保链接的URL有效(即:服务器没有做白名单限制)。


<head>
  
  <meta property="og:title" content="预览标题">
  <meta property="og:description" content="预览描述">
  <meta property="og:image:width" content="图片宽度">
  <meta property="og:image:height" content="图片高度">
  <meta property="og:image" content="预览图片的URL">
  <meta property="og:url" content="链接的URL">
  
  
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="预览标题">
  <meta name="twitter:description" content="预览描述">
  <meta property="twitter:image:width" content="图片宽度">
  <meta property="twitter:image:height" content="图片高度">
  <meta name="twitter:image" content="预览图片的URL">
  <meta name="twitter:url" content="链接的URL">
head>

下面我们做一些概念的整理、总结和学习。


3. 问题详解


3.1 什么是Open Graph协议?


Open Graph协议是一种用于在社交媒体平台上定义和传递网页元数据的协议。它由 Facebook 提出,并得到了其他社交媒体平台的支持和采纳。Open Graph 协议旨在标准化网页上的元数据,使网页在社交媒体上的分享和预览更加一致和可控。


通过在网页的 HTML  标签中添加特定的 meta 标签,使用 Open Graph 协议可以定义和传递与网页相关的元数据信息,如标题、描述、图片等。这些元数据信息可以被社交媒体平台解析和使用,用于生成链接预览、分享内容和提供更丰富的社交图谱。


使用 Open Graph 协议,网页的所有者可以控制链接在社交媒体上的预览内容,确保链接在分享时显示的标题、描述和图片等信息准确、有吸引力,并能够准确传达链接的主题和内容。这有助于提高链接的点击率、转化率和用户体验。


Open Graph 协议定义了一组标准的 meta 标签属性,如 og:titleog:descriptionog:image 等,用于提供链接预览所需的元数据信息。通过在网页中添加这些 meta 标签并设置相应的属性值,可以实现链接预览在社交媒体平台上的一致展示。


需要注意的是,Open Graph 协议是一种开放的标准,并不限于 Facebook 平台。其他社交媒体平台,如 Twitter、LinkedIn 等,也支持使用 Open Graph 协议定义和传递网页元数据,以实现链接预览的一致性。


图片


3.2 什么是Twitter Cards?


Twitter Cards 是一种由 Twitter 推出的功能,它允许网站所有者在他们的网页上定义和传递特定的元数据,以便在 Twitter 上分享链接时生成更丰富和吸引人的预览卡片。通过使用 Twitter Cards,网页链接在 Twitter 上的分享可以展示标题、描述、图片、链接和其他相关信息,以提供更具吸引力和信息丰富的链接预览。


Twitter Cards 提供了多种类型的卡片,以适应不同类型的内容和需求。以下是 Twitter Cards 的一些常见类型:



  • Summary CardSummary Card 类型的卡片包含一个标题、描述和可选的图片。它适用于分享文章、博客帖子等内容。

  • Summary Card with Large ImageSummary Card with Large Image 类型的卡片与 Summary Card 类型类似,但图片尺寸更大,更突出地展示在卡片上。

  • App CardApp Card 类型的卡片用于分享移动应用程序的信息。它包含应用的名称、图标、描述和下载按钮,以便用户可以直接从预览卡片中下载应用。

  • Player CardPlayer Card 类型的卡片用于分享包含媒体播放器的内容,如音频文件、视频等。它允许在预览卡片上直接播放媒体内容。


通过在网页的 HTML  标签中添加特定的 meta 标签,使用 Twitter Cards 可以定义和传递与链接预览相关的元数据信息,如标题、描述、图片、链接等。这些元数据信息将被 Twitter 解析和使用,用于生成链接预览卡片。


使用 Twitter Cards 可以使链接在 Twitter 上的分享更加吸引人和信息丰富,提高链接的点击率和用户参与度。它为网站所有者提供了更多控制链接在 Twitter 上展示的能力,并提供了一种更好的方式来呈现他们的内容。


图片


图片


结语


建立这个平台的初衷:



  • 打造一个仅包含前端问题的问答平台,让大家高效搜索处理同样问题。

  • 通过不断积累问题,一起练习逻辑思维,并顺便学习相关的知识点。

  • 遇到难题,遇到有共鸣的问题,一起讨论,一起沉淀,一起成长。

作者:程序员大澈
来源:juejin.cn/post/7310112330663231515
收起阅读 »

旋转、缩放、移动:掌握CSS Transform动画的终极指南!

在深入探讨CSS变形动画之前,让我们先探讨一下掌握它之后你可以实现哪些有趣的效果。学习了CSS变形动画之后,你将能够为你的网页添加引人注目的动态效果,例如创建一个立体的3D魔方,或者设计一个引人入胜的旋转菜单。这些仅仅是众多可能性中的一小部分,但或许可以勾起我...
继续阅读 »

在深入探讨CSS变形动画之前,让我们先探讨一下掌握它之后你可以实现哪些有趣的效果。

学习了CSS变形动画之后,你将能够为你的网页添加引人注目的动态效果,例如创建一个立体的3D魔方,或者设计一个引人入胜的旋转菜单。

Description
这些仅仅是众多可能性中的一小部分,但或许可以勾起我们的学习兴趣。

一、什么是CSS变形动画?

CSS变形动画是利用CSS3的transform属性创建的动画效果。它可以使元素旋转、缩放、倾斜甚至翻转,让静态的网页元素动起来,为用户带来更加丰富的交互体验。

坐标系统

首先我们要学习的变形动画,想达到在上图中出现的3D效果单纯的X与Y两个轴是实现不了的,还需要加入一条纵深轴,即Y轴的参与才有一个3D的视觉感受。

那么如何来理解X,Y,Z这三条轴的关系呢?可以看一下下面这张图。

Description

  • X轴代表水平轴

  • Y轴代表垂直轴

  • Z轴代表纵深轴

X和Y轴都非常好理解,怎么理解这个Z轴呢?

CSS的中文名称叫做层叠样式表,那么它肯定是一层一层的。之前学习过z-index就是用来设置层的优先级,优先级越高越在上面,也可以理解为离我们肉眼越近,它把优先级低的层给盖住了,所以Z轴可以理解为我们观察的视角与被观察物体之间的一条轴。

  • Z轴数值越大,说明观测距离越远。

  • Z轴的数值可以无限大,所以设置的时候一定要小心。

二、变形操作

使用 transform 来控制元素变形操作,包括控制移动、旋转、倾斜、3D转换等。

Description

下面我们通过一些例子来演示一下,比较常用的变形操作:

2.1 位移 translate()

translate()函数可以将元素向指定的方向移动,类似于position中的relative。或以简单的理解为,使用translate()函数,可以把元素从原来的位置移动,而不影响在X、Y轴上的任何Web组件。

想象一下,当你滚动页面时,一个元素平滑地从一个位置滑向另一个位置,这种流畅的过渡效果可以大大提升用户体验。

translate我们分为三种情况:

1)translate(x,y)水平方向和垂直方向同时移动(也就是X轴和Y轴同时移动)

2)translateX(x)仅水平方向移动(X轴移动)

3)translateY(Y)仅垂直方向移动(Y轴移动)

实例演示: 通过translate()函数将元素向Y轴下方移动50px,X轴右方移动100px。

HTML代码:

<div class="wrapper">
<div>我向右向下移动</div>
</div>

CSS代码:


.wrapper {
width: 200px;
height: 200px;
border: 2px dotted red;
margin: 20px auto;
}
.wrapper div {
width: 200px;
height: 200px;
line-height: 200px;
text-align: center;
background: orange;
color: #fff;
-webkit-transform: translate(50px,100px);
-moz-transform:translate(50px,100px);
transform: translate(50px,100px);
}

演示结果:

Description

2.2 旋转 rotate()

旋转rotate()函数通过指定的角度参数使元素相对原点进行旋转。旋转不仅可以是固定的度数,还可以是动态变化的,创造出无限的可能性。

它主要在二维空间内进行操作,设置一个角度值,用来指定旋转的幅度。如果这个值为正值,元素相对原点中心顺时针旋转;如果这个值为负值,元素相对原点中心逆时针旋转。如下图所示:

Description

HTML代码:

<div class="wrapper">
<div></div>
</div>

CSS代码:

.wrapper {
width: 200px;
height: 200px;
border: 1px dotted red;
margin: 100px auto;
}
.wrapper div {
width: 200px;
height: 200px;
background: orange;
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
}

演示结果:
Description

2.3 扭曲 skew()

扭曲skew()函数能够让元素倾斜显示。这种效果常常用于模拟速度感或者倾斜的视觉效果。

它可以将一个对象以其中心位置围绕着X轴和Y轴按照一定的角度倾斜。这与rotate()函数的旋转不同,rotate()函数只是旋转,而不会改变元素的形状。skew()函数不会旋转,而只会改变元素的形状。

Skew()具有三种情况:
1)skew(x,y)使元素在水平和垂直方向同时扭曲(X轴和Y轴同时按一定的角度值进行扭曲变形);

Description

第一个参数对应X轴,第二个参数对应Y轴。如果第二个参数未提供,则值为0,也就是Y轴方向上无斜切。

2)skewX(x)仅使元素在水平方向扭曲变形(X轴扭曲变形);
Description

3)skewY(y)仅使元素在垂直方向扭曲变形(Y轴扭曲变形)。
Description

示例演示:通过skew()函数将长方形变成平行四边形。

HTML代码:

<div class="wrapper">
<div>我变成平形四边形</div>
</div>

CSS代码:

.wrapper {
width: 300px;
height: 100px;
border: 2px dotted red;
margin: 30px auto;
}
.wrapper div {
width: 300px;
height: 100px;
line-height: 100px;
text-align: center;
color: #fff;
background: orange;
-webkit-transform: skew(45deg);
-moz-transform:skew(45deg)
transform:skew(45deg);
}

演示结果:
Description

2.4 缩放 scale()

缩放 scale()函数 让元素根据中心原点对对象进行缩放。这不仅可以用来模拟放大镜效果,还可以创造出元素的进入和退出动画,比如一个图片慢慢缩小直至消失。

缩放 scale 具有三种情况:

1) scale(X,Y)使元素水平方向和垂直方向同时缩放(也就是X轴和Y轴同时缩放)。

Description
例如:

div:hover {
-webkit-transform: scale(1.5,0.5);
-moz-transform:scale(1.5,0.5)
transform: scale(1.5,0.5);
}

注意:Y是一个可选参数,如果没有设置Y值,则表示X,Y两个方向的缩放倍数是一样的。

2)scaleX(x)元素仅水平方向缩放(X轴缩放)
Description
3)scaleY(y)元素仅垂直方向缩放(Y轴缩放)
Description
HTML代码:

<div class="wrapper">
<div>我将放大1.5倍</div>
</div>

CSS代码:


.wrapper {
width: 200px;
height: 200px;
border:2px dashed red;
margin: 100px auto;
}
.wrapper div {
width: 200px;
height: 200px;
line-height: 200px;
background: orange;
text-align: center;
color: #fff;
}
.wrapper div:hover {
opacity: .5;
-webkit-transform: scale(1.5);
-moz-transform:scale(1.5)
transform: scale(1.5);
}

演示结果:
Description
注意: scale()的取值默认的值为1,当值设置为0.01到0.99之间的任何值,作用使一个元素缩小;而任何大于或等于1.01的值,作用是让元素放大。


想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!

2.5 矩阵 matrix()

matrix() 是一个含六个值的(a,b,c,d,e,f)变换矩阵,用来指定一个2D变换,相当于直接应用一个[a b c d e f]变换矩阵。就是基于水平方向(X轴)和垂直方向(Y轴)重新定位元素。

此属性值使用涉及到数学中的矩阵,我在这里只是简单的说一下CSS3中的transform有这么一个属性值,如果需要深入了解,需要对数学矩阵有一定的知识。

示例演示:通过matrix()函数来模拟transform中translate()位移的效果。
HTML代码:

<div class="wrapper">
<div></div>
</div>

CSS代码:

.wrapper {
width: 300px;
height: 200px;
border: 2px dotted red;
margin: 40px auto;
}
.wrapper div {
width:300px;
height: 200px;
background: orange;
-webkit-transform: matrix(1,0,0,1,50,50);
-moz-transform:matrix(1,0,0,1,50,50);
transform: matrix(1,0,0,1,50,50);
}

演示结果:

Description

2.6 原点 transform-origin

任何一个元素都有一个中心点,默认情况之下,其中心点是居于元素X轴和Y轴的50%处。如下图所示:

Description

在没有重置transform-origin改变元素原点位置的情况下,CSS变形进行的旋转、位移、缩放,扭曲等操作都是以元素自己中心位置进行变形。

但很多时候,我们可以通过transform-origin来对元素进行原点位置改变,使元素原点不在元素的中心位置,以达到需要的原点位置。

transform-origin取值和元素设置背景中的background-position取值类似,如下表所示:

Description

示例演示:

通过transform-origin改变元素原点到左上角,然后进行顺时旋转45度。

HTML代码:

<<div class="wrapper">
<div>原点在默认位置处</div>
</div>
<div class="wrapper transform-origin">
<div>原点重置到左上角</div>
</div>

CSS代码:

.wrapper {
width: 300px;
height: 300px;
float: left;
margin: 100px;
border: 2px dotted red;
line-height: 300px;
text-align: center;
}
.wrapper div {
background: orange;
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
}
.transform-origin div {
-webkit-transform-origin: left top;
transform-origin: left top;
}

演示结果:
Description

以上就是css动画中几种基本的变形技巧了,掌握这些我们可以操控我们的网页元素实现我们想要的一些基本动画效果。

在这个充满创造力的时代,CSS变形动画是每个前端开发者必备的技能。它不仅能提升用户体验,更能激发设计师和开发者的创意火花。所以,不妨尝试一下,让你的网页动起来,给用户留下深刻的印象吧!

收起阅读 »

🎉🎉 环信 IM 客户端将适配鸿蒙 HarmonyOS

自华为推出了自主研发操作系统鸿蒙 HarmonyOS 后,国内许多应用软件开始陆续全面兼容和接入鸿蒙操作系统。环信 IM 客户端计划将全面适配统鸿蒙 HarmonyOS ,助力开发者快速实现社交娱乐、语聊房、在线教育、智能硬件、社交电商、在线金融、线上医疗等广...
继续阅读 »

自华为推出了自主研发操作系统鸿蒙 HarmonyOS 后,国内许多应用软件开始陆续全面兼容和接入鸿蒙操作系统。环信 IM 客户端计划将全面适配统鸿蒙 HarmonyOS ,助力开发者快速实现社交娱乐、语聊房、在线教育、智能硬件、社交电商、在线金融、线上医疗等广泛场景的即时消息互动。




环信 IM 客户端适配后,将为开发者提供鸿蒙 HarmonyOS 系统上单聊、群聊、会话等能力和服务,具体覆盖到以下:

  • 消息管理:收发消息、全消息类型支持、会话管理、消息回执、消息撤回等
  • 用户属性:用户头像、用户昵称、自定义属性等
  • 群组管理:群成员管理、群管理员、群文件、群公告等
  • 离线推送:主流推送厂商支持,推送模板设置、推送通知等
  • 多设备同步:多设备消息和事件同步,支持设备同步策略设置等

作为一直深耕在即时通信领域的老兵,在平台框架开发支持上,环信 IM 已经支持 Android、iOS、macOS、Windows、Linux、Web、Flutter、Unity、Electron、React Native、Uni-App、APICloud 等。尤其海外开发者重点关注的 React Native、Flutter 平台,游戏开发者关注的 Unity 平台,以及原生桌面应用 Windows 平台,环信保持了持续更新迭代。

此次将增加鸿蒙HarmonyOS 的 SDK 适配,后续会持续优化和适配鸿蒙系统特性,助力开发者在鸿蒙HarmonyOS 系统上实现更加稳定、优质的即时消息互动。

环信 IM 目前已支持的平台:


在移动端 SDK 性能上,环信能做到小包体 SDK,提升终端设备运行性能,并且 SDK 崩溃率低于 0.005%,远低于行业平均水平 0.01%。

iOS 引入 SDK 前后 App 大小对比


Android 引入 SDK 前后 App 大小对比



在消息传输的延时性上,环信拥有新加坡、美国、德国等几大数据中心,全球部署近千终端网络加速节点,覆盖全球230多个国家和地区,实现全球端到端时延<100毫秒,网络连通率>99.95%,并支持多链路智能路由,遇到运营商网络故障自动切换路由。

扩展:

收起阅读 »

Android应用内版本更新:使用BasicUI库的简单实现

在移动应用开发中,应用内版本更新是一项重要的功能。它允许开发者轻松地向用户提供新的应用版本,以修复错误、改进性能,或者引入新功能。这篇文章将介绍如何使用 BasicUI 库,一个Android库,来实现应用内版本更新的功能。我们将演示如何从远程服务器下载APK...
继续阅读 »

在移动应用开发中,应用内版本更新是一项重要的功能。它允许开发者轻松地向用户提供新的应用版本,以修复错误、改进性能,或者引入新功能。这篇文章将介绍如何使用 BasicUI 库,一个Android库,来实现应用内版本更新的功能。我们将演示如何从远程服务器下载APK文件并进行安装。


BasicUI库简介


BasicUI 是一个功能强大且易于使用的Android库,用于实现各种常见UI和网络操作,其中包括文件下载和更新功能。这个库提供了一些便捷的方法来简化Android应用开发中的一些常见任务,包括版本更新。


要开始使用BasicUI库,你需要在你的项目中添加相应的依赖,可以在官方GitHub仓库中找到详细的文档和示例。


GitHub库链接: BasicUI


应用内部升级弹窗的流程图


image.png


代码实现应用内版本更新


下面是一个简单的代码示例,演示了如何使用BasicUI库来实现应用内版本更新。这段代码将从远程服务器下载APK文件,并在下载完成后进行安装。请确保你已经添加了BasicUI库的依赖。


val file = File(cacheDir, "update.apk")
if (file.exists()) {
file.delete()
}
mDialog.apply {
setOnCancelListener {
HttpUtils.cancel()
}
}.show()
with(this@OkHttpActivity)
.url("http://example.com/your_update.apk") // 替换成实际的APK下载链接
.downloadSingle()
.file(file)
.exectureDownload(object : DownloadCallback {
override fun onFailure(e: Exception?) {
LogUtils.e(e!!.message)
mDialog.dismiss()
}

override fun onSucceed(file: File?) {
ToastUtils.showShort("文件下载完成")
LogUtils.e("文件保存的位置:" + file!!.absolutePath)
mProgressBar!!.visibility = View.GONE
mProgressBar!!.progress = 0
installApk(file)
mDialog.dismiss()
}

override fun onProgress(progress: Int) {
LogUtils.e("单线程下载APK的进度:$progress")
mProgressBar!!.progress = progress
mProgressBar!!.visibility = View.VISIBLE
}
})

上述代码的主要步骤包括:



  1. 创建一个用于保存下载APK文件的本地文件,要使用cacheDir目录,原因是可以不需要读写权限。

  2. 如果之前存在同名文件,先进行删除。

  3. 创建一个对话框,其中包括一个取消监听器,用于在用户取消下载时取消网络请求。

  4. 使用BasicUI库的网络操作类(HttpUtils)创建一个下载请求,指定下载地址、下载完成后保存的文件,以及下载回调接口。

  5. 在下载回调接口中处理下载成功、失败和进度更新的情况。


请注意,你需要将示例代码中的下载链接替换为实际的APK下载链接。这段代码提供了一个简单而有效的方式来执行应用内版本更新,但你还可以根据你的需求进行进一步的定制化。


结语


在本文中,我们演示了如何使用BasicUI库来实现Android应用内版本更新的功能。这是一个快速、方便的解决方案,可以帮助你轻松地向用户提供最新版本的应用程序。请记住,版本更新是确保用户始终使用最新、最稳定版本的应用的关键步骤。


为了更好地满足你的需求,你可以根据实际情况进一步定制版本更新流程,例如添加灰度发布、自动检测新版本等功能。希望这篇文章对你有所帮助,使你能够更好地满足用户的需求和提供卓越的应用体验。




这篇文章演示了如何使用 BasicUI 库来实现应用内版本更新的功能。你可以根据自己的需求进一步定制这个流程,以满足特定的应用程序要求。希望这篇文章对你有所帮助!


作者:peakmain9
来源:juejin.cn/post/7293401255053819941
收起阅读 »

个人或个体户,如何免费使用微信小程序授权登录

web
需求 个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部? 微信授权登录好处: 不用自己开发一个登录模块,节省开发和维护成本 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇 可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年...
继续阅读 »

需求


个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部?


微信授权登录好处:



  1. 不用自己开发一个登录模块,节省开发和维护成本

  2. 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇


可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年要300元,小公司得省钱啊!


实现步骤说明


所有的步骤里包含四个对象,分别是本地后台本地微信小程序本地网页、以及第三方微信后台



  1. 本地后台调用微信后台https://api.weixin.qq.com/cgi-bin/token接口,get请求,拿到返回的access_token

  2. 本地后台根据拿到的access_token,调用微信后台https://api.weixin.qq.com/wxa/getwxacodeunlimit接口,得到二维码图片文件,将其输出传递给本地网页显示

  3. 本地微信小程序本地网页的二维码图片,跳转至小程序登录页面,通过wx.login方法,在success回调函数内得到code值,并将该值传递给本地后台

  4. 本地后台拿到code值后,调用微信后台https://api.weixin.qq.com/sns/jscode2session接口,get请求,得到用户登录的openid即可。



注意点:



  1. 上面三个微信接口/cgi-bin/token/getwxacodeunlimit/jscode2session必须由本地后台调用,微信小程序那边做了前端限制;

  2. 本地网页如何得知本地微信小程序已扫码呢?


本地微信小程序code,通过A接口,将值传给后台,后台拿到openid后,再将成功结果返回给本地微信小程序;同时,本地网页不断地轮询A接口,等待后台拿到openid后,便显示登录成功页面。



微信小程序核心代码


Page({
data: {
theme: wx.getSystemInfoSync().theme,
scene: "",
jsCode: "",
isLogin: false,
loginSuccess: false,
isChecked: false,
},
onLoad(options) {
const that = this;
wx.onThemeChange((result) => {
that.setData({
theme: result.theme,
});
});
if (options !== undefined) {
if (options.scene) {
wx.login({
success(res) {
if (res.code) {
that.setData({
scene: decodeURIComponent(options.scene),
jsCode: res.code,
});
}
},
});
}
}

},
handleChange(e) {
this.setData({
isChecked: Boolean(e.detail.value[0]),
});
},
formitForm() {
const that = this;
if (!this.data.jsCode) {
wx.showToast({
icon: "none",
title: "尚未微信登录",
});
return;
}
if (!this.data.isChecked) {
wx.showToast({
icon: "none",
title: "请先勾选同意用户协议",
});
return;
}
wx.showLoading({
title: "正在加载",
});
let currentTimestamp = Date.now();
let nonce = randomString();
wx.request({
url: `A接口?scene=${that.data.scene}&js_code=${that.data.jsCode}`,
header: {},
method: "POST",
success(res) {
wx.hideLoading();
that.setData({
isLogin: true,
});
if (res.statusCode == 200) {
that.setData({
loginSuccess: true,
});
} else {
if (res.statusCode == 400) {
wx.showToast({
icon: "none",
title: "无效请求",
});
} else if (res.statusCode == 500) {
wx.showToast({
icon: "none",
title: "服务内部错误",
});
}
that.setData({
loginSuccess: false,
});
}
},
fail: function (e) {
wx.hideLoading();
wx.showToast({
icon: "none",
title: e,
});
},
});
},
});


scene为随机生成的8位数字


本地网页核心代码


    let isInit = true
function loginWx() {
isInit = false
refreshQrcode()
}
function refreshQrcode() {
showQrLoading = true
showInfo = false
api.get('/qrcode').then(qRes => {
if (qRes.status == 200) {
imgSrc = `${BASE_URL}${qRes.data}`
pollingCount = 0
startPolling()
} else {
showToast = true
toastMsg = '二维码获取失败,请点击刷新重试'
showInfo = true
}
}).finally(() => {
showQrLoading = false
})
}

// 开始轮询
// 1000毫秒轮询一次
function startPolling() {
pollingInterval = setInterval(function () {
pollDatabase()
}, 1000)
}
function pollDatabase() {
if (pollingCount >= maxPollingCount) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
return
}
pollingCount++
api.get('/result').then(res => {
if (res.status == 200) {
clearInterval(pollingInterval)
navigate('/os', { replace: true })
} else if (res.status == 408) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
}
})
}



html的部分代码如下所示


     <button class="btn" on:click={loginWx}>微信登录</button>
<div id="qrcode" class="relative mt-10">
{#if imgSrc}
<img src={imgSrc} alt="二维码图片"/>
{/if}
{#if showQrLoading}
<div class="mask absolute top-0 left-0 w-full h-full z-10">
<Loading height="12" width="12"/>
</div>
{/if}
</div>

尾声


若需要完整代码,或想知道如何申请微信小程序,欢迎大家关注或私信我哦~~


作者:zwf193071
来源:juejin.cn/post/7351649413401493556
收起阅读 »

🚫为了防止狗上沙发,写了一个浏览器实时识别目标功能📷

web
背景 家里有一条狗🐶,很喜欢乘人不备睡沙发🛋️,恰好最近刚搬家 + 狗迎来了掉毛期 不想让沙发上很多毛。所以希望能识别到狗,然后播放“gun 下去”的音频📣。 需求分析 需要一个摄像头📷 利用 chrome 浏览器可以调用手机摄像头,获取权限,然后利用 ...
继续阅读 »

背景



家里有一条狗🐶,很喜欢乘人不备睡沙发🛋️,恰好最近刚搬家 + 狗迎来了掉毛期 不想让沙发上很多毛。所以希望能识别到狗,然后播放“gun 下去”的音频📣。


需求分析



  • 需要一个摄像头📷

    • 利用 chrome 浏览器可以调用手机摄像头,获取权限,然后利用 video 将摄像头的内容绘制到 video 上。



  • 通过摄像头实时识别画面中的狗🐶

    • 利用 tensorflow 和预训练的 COCO-SSD MobileNet V2 模型进行对象检测。

    • 将摄像头的视频流转化成视频帧图像传给模型进行识别



  • 录制一个音频

    • 识别到目标(狗)后播放音频📣



  • 需要部署在一个设备上

    • 找一个不用的旧手机📱,Android 系统

    • 安装 termux 来实现开启本地 http 服务🌐




技术要点



  1. 利用浏览器 API 调用手机摄像头,将视频流推给 video


    const stream = await navigator.mediaDevices.getUserMedia({
    // video: { facingMode: "environment" }, // 摄像头后置
    video: { facingMode: "user" },
    });

    const videoElement = document.getElementById("camera-stream");
    videoElement.srcObject = stream;


  2. 加载模型,实现识别


    let dogDetector;

    async function loadDogDetector() {
    // 加载预训练的SSD MobileNet V2模型
    const model = await cocoSsd.load();
    dogDetector = model; // 将加载好的模型赋值给dogDetector变量
    }


  3. 监听 video 的播放,将视频流转换成图像传入模型检测


    videoElement.addEventListener("play", async () => {
    requestAnimationFrame(processVideoFrame);
    });

    async function processVideoFrame() {
    if (!videoElement.paused && !videoElement.ended) {
    canvas.width = videoElement.videoWidth;
    canvas.height = videoElement.videoHeight;
    ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);

    // 获取当前帧图像数据
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // 对帧执行预测
    let predictionClasses = "";
    const predictions = await dogDetector.detect(imageData);
    // 处理预测结果,比如检查是否有狗被检测到
    for (const prediction of predictions) {
    predictionClasses += `${prediction.class}\n`; // 组装识别的物体名称
    if (prediction.class === "dog") {
    // 播放声音
    playDogBarkSound();
    }
    }
    nameContainer.innerText = predictionClasses.trim(); // 移除末尾的换行符

    requestAnimationFrame(processVideoFrame);
    }
    }


  4. 播放音频


    async function playDogBarkSound() {
    if (playing) return;
    playing = true;
    const audio = new Audio(dogBarkSound);
    audio.addEventListener("ended", () => {
    playing = false;
    });
    audio.volume = 0.5; // 调整音量大小
    await audio.play();
    }


  5. 手机开启本地 http 服务



    • 安装 termux

    • 安装 python3

    • 运行 python3 -m http.server 8000



  6. 将项目上传到 termux 的目录





项目代码(改为 html 文件后)


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile Dog Detector</title>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.17.0/dist/tf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd@2.2.3/dist/coco-ssd.min.js"></script>
<style>
#camera-stream {
width: 200px;
height: auto;
}
#name {
height: 200px;
overflow-y: auto;
font-family: Arial, sans-serif;
}
</style>
</head>
<body>
<video id="camera-stream" autoplay playsinline></video>
<div id="name" style="height: 200px"></div>

<script>
let playing = false;
let dogDetector;

async function loadDogDetector() {
// 加载预训练的SSD MobileNet V2模型
const model = await cocoSsd.load();
dogDetector = model; // 将加载好的模型赋值给dogDetector变量
console.log("dogDetector", dogDetector);
startCamera();
}
// 调用函数加载模型
loadDogDetector();

async function startCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
// video: { facingMode: "environment" }, // 摄像头后置
video: { facingMode: "user" },
});
const nameContainer = document.getElementById("name");
const videoElement = document.getElementById("camera-stream");
videoElement.srcObject = stream;

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

videoElement.addEventListener("play", async () => {
requestAnimationFrame(processVideoFrame);
});
async function processVideoFrame() {
if (!videoElement.paused && !videoElement.ended) {
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);

const imageData = ctx.getImageData(
0,
0,
canvas.width,
canvas.height
);

let predictionClasses = "";
const predictions = await dogDetector.detect(imageData);
for (const prediction of predictions) {
predictionClasses += `${prediction.class}\n`;
if (prediction.class === "dog") {
// 修改为检测到狗时播放声音
playDogBarkSound();
}
}
nameContainer.innerText = predictionClasses.trim();

requestAnimationFrame(processVideoFrame);
}
}

async function playDogBarkSound() {
if (playing) return;
playing = true;
const audio = new Audio("./getout.mp3");
audio.addEventListener("ended", () => {
playing = false;
});
audio.volume = 0.5; // 调整音量大小
await audio.play();
}
}
</script>
</body>
</html>

实现效果


效果很好👍,用旧手机开启摄像头后,检测到狗就播放声音了。


但是,家里夫人直接做了一个围栏晚上给狗圈起来了🚫



实现总结


该方案通过以下步骤实现了一个基于网页的实时物体检测系统,专门用于识别画面中的狗并播放特定音频以驱赶它离开沙发。具体实现过程包括以下几个核心部分:



  • 调用摄像头:


使用浏览器提供的 navigator.mediaDevices.getUserMedia API 获取用户授权后调用手机摄像头,并将视频流设置给 video 元素展示。



  • 加载物体检测模型:


使用 TensorFlow.js 和预训练的 COCO-SSD MobileNet V2 模型进行对象检测,加载模型后赋值给 dogDetector 变量。
处理视频流与图像识别:


监听 video 元素的播放事件,通过 requestAnimationFrame 循环逐帧处理视频。
将当前视频帧绘制到 canvas 上,然后从 canvas 中提取图像数据传入模型进行预测。
在模型返回的预测结果中,如果检测到“dog”,则触发播放音频函数。



  • 播放音频反馈:


定义一个异步函数 playDogBarkSound 来播放指定的音频文件,确保音频只在前一次播放结束后才开始新的播放。



  • 部署环境准备:


使用旧 Android 手机安装 Termux,创建本地 HTTP 服务器运行项目代码。
上传项目文件至 Termux 目录下并通过访问 localhost:8000 启动应用。


通过以上技术整合,最终实现了在旧手机上部署一个能够实时检测画面中狗的网页应用,并在检测到狗时播放指定音频。


作者:前端小蜗
来源:juejin.cn/post/7345672631323394098
收起阅读 »

Androidmanifest文件加固和对抗

前言 恶意软件为了不让我们很容易反编译一个apk,会对androidmanifest文件进行魔改加固,本文探索androidmanifest加固的常见手法以及对抗方法。这里提供一个恶意样本的androidmanifest.xml文件,我们学完之后可以动手实践。...
继续阅读 »

前言


恶意软件为了不让我们很容易反编译一个apk,会对androidmanifest文件进行魔改加固,本文探索androidmanifest加固的常见手法以及对抗方法。这里提供一个恶意样本的androidmanifest.xml文件,我们学完之后可以动手实践。


1、Androidmanifest文件组成


这里贴一张经典图,主要描述了androidmanifest的组成


image


androidmanifest文件头部仅仅占了8个字节,紧跟其后的是StringPoolType字符串常量池


(为了方便我们观察分析,可以先安装一下010editor的模板,详细见2、010editor模板)


Magic Number


这个值作为头部,是经常会被魔改的,需要重点关注


image


StylesStart


该值一般为0,也是经常会发现魔改


image


StringPool


image


寻找一个字符串,如何计算?


1、获得字符串存放开放位置:0xac(172),此时的0xac是不带开头的8个字节


所以需要我们加上8,最终字符串在文件中的开始位置是:0xb4


2、获取第一个字符串的偏移,可以看到,偏移为0


image


3、计算字符串最终存储的地方: 0xb4 = 0xb4 + 0


读取字符串,以字节00结束


image


读取到的字符为:theme


帮助网安学习,全套资料S信领取:


① 网安学习成长路径思维导图

② 60+网安经典常用工具包

③ 100+SRC漏洞分析报告

④ 150+网安攻防实战技术电子书

⑤ 最权威CISSP 认证考试指南+题库

⑥ 超1800页CTF实战技巧手册

⑦ 最新网安大厂面试题合集(含答案)

⑧ APP客户端安全检测指南(安卓+IOS)


总结:


stringpool是紧跟在文件头后面的一块区域,用于存储文件所有用到的字符串


这个地方呢,也是经常发生魔改加固的,比如:将StringCount修改为0xFFFFFF无穷大


在经过我们的手动计算和分析后,我们对该区域有了更深的了解。


2、010editor模板


使用010editor工具打开,安装模板库


image


搜索:androidmanifest.bt


image


安装完成且运行之后:


image


会发现完整的结构,帮助我们分析


3、使用AXMLPrinter2进行的排错和修复


用法十分简单:


java -jar AXMLPrinter2.jar AndroidManifest_origin.xml

会有一系列的报错,但是不要慌张,根据这些报错来对原androidmanifest.xml进行修复


image​​


意思是:出乎意料的0x80003(正常读取的数据),此时却读取到:0x80000


按照小端序,正常的数据应该是: 03 00 08


使用 010editor 打开


image


将其修复


image


保存,再次尝试运行AXMLPrinter2


image


好家伙还有错误,这个-71304363,不方便我们分析,将其转换为python的hex数据


NegativeArraySizeException 表示在创建数组的时候,数组的大小出现了负数。


androidmanifest加固后文件与正常的androidmanifest文件对比之后就可以发现魔改的地方。


image


将其修改回去


image


运行仍然报错,是个新错误:


image


再次去分析:


image


stringoffsets如此离谱,并且数组的大小变为了0xff


image


image


根据报错的信息,尝试把FF修改为24


image


image


再次运行


image


成功拿到反编译后的androidmanifest.xml文件


总结:


这个例子有三个魔改点经常出现在androidmanifest.xml加固


恶意软件通过修改这些魔改点来对抗反编译


作者:合天网安实验室
来源:juejin.cn/post/7324011299272310811
收起阅读 »

前端在线预览播放视频方案,dpPlayer

web
华为云生成obs链接时,可以做配置。 视频是用来预览的 视频是用来下载的 一般我们播放本地视频都是使用vedio标签,但是vedio标签只支持三种视频格式:MP4、WebM、Ogg,对于在线视频直接使用vedio不支持播放。 故,上述 2 中的视频,在ve...
继续阅读 »

华为云生成obs链接时,可以做配置。



  1. 视频是用来预览

  2. 视频是用来下载


一般我们播放本地视频都是使用vedio标签,但是vedio标签只支持三种视频格式:MP4、WebM、Ogg,对于在线视频直接使用vedio不支持播放。
故,上述 2 中的视频,在vedio中不支持播放,浏览器访问链接,直接就下载了。


先介绍几个概念:


流协议: 流协议就是在两个通信系统之间传输多媒体文件的一套规则,它定义了视频文件将如何分解为小数据包以及它们在互联网上传输的顺序,RTMP与 RTSP 是比较常见的流媒体协议。


HLS: HLS (HTTP Live Streaming)是Apple的动态码率自适应技术。主要用于PC和Apple终端的音视频服务。包括一个m3u(8)的索引文件,TS媒体分片文件和key加密串文件。参考:HLS。简单来说,HLS是一种协议,如果你的视频源是http://xxxx.m3u8这种,就选择这种协议,.m3u8是个文本文件,直播时,他的内容实时变更,内部指向一个或多个.ts文件。


HTTP-FLV: HTTP-FLV 是将音视频数据以 FLV 文件格式进行封装,再将 FLV 格式数据封装在 HTTP 协议中进行传输的一种流媒体传输方式。HTTP-FLV 的实现原理: HTTP-FLV 利用 HTTP/1.1 分块传输机制发送 FLV 数据。虽然直播服务器无法知道直播流的长度,但是 HTTP/1.1 分块传输机制可以不填写 conten-length 字段而是携带 Transfer-Encoding: chunked 字段,这样客户端就会一直接受数据。参考:FLV 和 HTTP-FLV

简单来说就是你的视频源是直播且是xxxx.flv,就选择这种协议播放。还有个websocket-flv,是基于websocket的。


RTMP与RTSP: 什么是RTMP 和 RTSP?它们之间有什么区别?


H264(AVC)与H265(HEVC): 都是视频编码,是视频压缩格式,由于视频本身的码流太大,所以需要经过压缩然后再通过网络进行传输,其中H265是H264的升级版,很多播放器无法播放H265视频。




xgplayer


vue2的系统,本来用xgplayer 版本:2.32.5。无奈本地可以展示,测试环境不能用,报错不明显,粗略看了一下是插件底层,内部报错,故放弃xgpalyer插件。


ps.我在vue3的系统中,用过xgpalyer插件,挺好用的


优点如下:



  • 官网教程非常简单清晰,上手快

  • 使用起来体验感很好

  • 支持直播点播,支持hls、http+flv、dash、WebRTC直播,还有音乐播放器 。

  • 提供在线可调试demo


dpplayer


然后,我就换了 dppalyer插件来展示。点击查看中文文档


这个插件,我去github查了一下,15k星星,用的人还是挺多,但是,个人感觉不如 xgplayer好用。


安装npm install dplayer --save


在页面中引用


import DPlayer from 'dplayer';

const dp = new DPlayer(options);

dpplayer实现是通过生成iframe页面,将视频嵌套到其中。


刚开始给容器写了样式,宽100% 高100%,结果它不能自适应屏幕,很难受。后面我强行定宽420px。高度自动获取当前容器高度,定了一个最大高度。


但其实没有用,它会根据宽度,自己按比例缩放高度。
所以我在视频渲染出来后,自动调了一下全屏功能dpPlayer.fullScreen.request('web');
勉强解决了这个问题。


贴一下我的完整代码


<template>
<div class="vedio-wrapper" :style="{'max-height': winH}">
<el-empty v-if="!player" description="暂无数据"></el-empty>
<div :id="id" allowfullscreen="allowfullscreen" />
</div>

</template>


<script>
import DPlayer from 'dplayer';

import { getParam } from '@/utils/utils'
import {
getBucketObsFileUrl
} from '@/api/common'

export default {
name: 'previewMedia',
components:{},
data() {
return {
winH: '300px',
id: 'dpPlayerDom',
player: null
}

},
created() {
const winH = window.innerHeight
this.winH = winH + 'px'
},
mounted() {
this.getFileUrl()
},
methods: {

async getFileUrl() {
try {
const filePath = getParam('filePath')
const type = getParam('type') ? parseInt(getParam('type')) : 1
if (!filePath) return
const params = {
objectKey: filePath,
type
}
const data = await getBucketObsFileUrl(params);
this.setVedioplayerConfig(data)
} catch (e) {
console.error(e)
}
},

setVedioplayerConfig(url) {
if (!url) return

const tmpConfig = {
container: document.getElementById('dplayer'),
screenshot: false,
video: {
url: url,
thumbnails: 'thumbnails.jpg',
},
contextmenu: []

}

this.$nextTick(() => {
tmpConfig.container = document.getElementById(this.id)
const dpPlayer = new DPlayer(tmpConfig);
this.player = dpPlayer

dpPlayer.fullScreen.request('web');
})

}
}
}
</script>

<style scoped lang="scss">
.vedio-wrapper {
width: 400px;
height: 100%;
margin: 0 auto;
}
</style>



作者:山间板栗
来源:juejin.cn/post/7355456165244239912
收起阅读 »