Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replaces *.initializeView with *.viewStarter #602

Merged
merged 2 commits into from Dec 10, 2021

Conversation

rjrjr
Copy link
Contributor

@rjrjr rjrjr commented Dec 8, 2021

Fixes #597.

It used to be the case that every DecorativeViewFactory called
showRendering() twice (#397). We fixed that (we thought) by introducing the
initializeView lambda to ViewRegistry.buildView and
DecorativeViewFactory (#408).

Unfortunately, that fix botched recursion. Individual DecorativeViewFactory
instances work fine, but if you wrap them you still get one showRendering
call from each. Worse, upstream initializeView lambdas are clobbered by
immediately downstream ones. e.g., when a WorkflowViewStub shows a
DecorativeViewFactory, the WorkflowLifecycleRunner.installOn call in the
former is clobbered.

The fix is to completely decouple building a view from from this kind of
initialization. ViewRegistry.buildView and its wrappers no longer try to
call showRendering at all.

Instead the caller of buildView (mostly WorkflowViewStub) is reponsible
for immediately calling View.start on the new View. View.start makes
the initial showRendering call that formerly was the job of
ViewFactory.buildView -- the factory builds the view, and the container
turns the key. Since View.start is called only after all wrapped
ViewFactory.buildView functions have executed, we're certain it will only
happen once.

Of course we still need the ability to customize view initialization via
wrapping, especially to invoke WorkflowLifecycleOwner.installOn. To
accomodate that, the function that View.start executes can be wrapped via
the new viewStarter argument to ViewRegistry.buildView and
DecorativeViewFactory, which replaces initializeView.

This required a pretty thorough overhaul of ViewShowRendering.kt
The ViewShowRenderingTag that it hangs off of a view tag is renamed
WorkflowViewState, and extracted to a separate file.

WorkflowViewState is a sealed class with two implementations (New and
Started) to help us enforce the order of the ViewRegistry.buildView,
View.bindShowRendering, View.start and View.showRendering calls.

@rjrjr rjrjr force-pushed the ray/initializeView-is-a-landmine branch 5 times, most recently from b6360db to cb38765 Compare December 10, 2021 00:36
@rjrjr rjrjr changed the title Adds (failing) DecorativeViewFactory double update test Replaces *.initializeView with *.viewStarter Dec 10, 2021
@rjrjr rjrjr force-pushed the ray/initializeView-is-a-landmine branch 5 times, most recently from a6cfb05 to 42d0231 Compare December 10, 2021 01:11
@rjrjr rjrjr marked this pull request as ready for review December 10, 2021 01:33
@rjrjr rjrjr requested review from zach-klippenstein and a team as code owners December 10, 2021 01:33
@zach-klippenstein
Copy link
Collaborator

image

* created (that is, immediately after the call to [ViewFactory.buildView]).
* [showRendering], [getRendering] and [environment] are all available when this is called.
* Defaults to a call to [View.showFirstRendering].
* @param viewStarter DOCUMENT ME RAY
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oopsy, that was supposed to shame me into writing the doc before asking for review.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

 *
 * @param viewStarter An optional wrapper for the function invoked when [View.start]
 * is called, allowing for last second initialization of a newly built [View].
 * See [ViewStarter] for details.
/**
 * A wrapper for the function invoked when [View.start] is called, allowing for
 * last second initialization of a newly built [View]. Provided via [ViewRegistry.buildView]
 * or [DecorativeViewFactory.viewStarter].
 *
 * While [View.getRendering] may be called from [startView], it is not safe to
 * assume that the type of the rendering retrieved matches the type the view was
 * originally built to display. [ViewFactories][ViewFactory] can be wrapped, and
 * renderings can be mapped to other types.
 */
@WorkflowUiExperimentalApi
public fun interface ViewStarter {
  /** Called from [View.start]. [doStart] must be invoked. */
  public fun startView(
    view: View,
    doStart: () -> Unit
  )
}

Comment on lines 12 to 13
public typealias ViewShowRendering<RenderingT> =
(@UnsafeVariance RenderingT, ViewEnvironment) -> Unit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the variance that is not matching here?

by nature of only being an input parameter on the function is it wanting you to make RenderingT explicitly contravariant to ViewShowRendering?

Haven't tested, but my guess is

Suggested change
public typealias ViewShowRendering<RenderingT> =
(@UnsafeVariance RenderingT, ViewEnvironment) -> Unit
public typealias ViewShowRendering<in RenderingT> =
(RenderingT, ViewEnvironment) -> Unit

Should work without the Unsafe annotation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's been a while, but I think it's because that change is theoretically required but you're not allowed to do it with a typealias.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah gotcha. I kind of remember you saying something like that!

get() = getTag(R.id.workflow_ui_view_state) as? WorkflowUiViewState<*>

@WorkflowUiExperimentalApi
private var View.workflowTag: WorkflowUiViewState<*>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private var View.workflowTag: WorkflowUiViewState<*>
private var View.workflowUiViewState: WorkflowUiViewState<*>

Tag (while it is the implementation) kept tripping me up. Not convinced this must be done but just a thought.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems helpful to me, will do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And maybe rename WorkflowUiViewState to just WorkflowViewState while we're at it.

Comment on lines 48 to 50
* Establishes [showRendering] as the implementation of [View.showRendering]
* for the receiver, possibly replacing the existing one. Likewise sets / updates
* the values returned by [View.getRendering] and [View.environment].
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Establishes [showRendering] as the implementation of [View.showRendering]
* for the receiver, possibly replacing the existing one. Likewise sets / updates
* the values returned by [View.getRendering] and [View.environment].
* Establishes [showRendering] as the implementation of [View.showRendering]
* for the receiver, possibly replacing the existing one. Likewise sets / updates
* the values returned by [View.getRendering] and [View.environment] by setting
* the [WorkflowUiViewState] tag on the View receiver.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember that WorkflowUiViewState is a private type.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha.

) {
workflowTag = when (workflowTagOrNull) {
is New<*> -> New(initialRendering, initialViewEnvironment, showRendering, starter)
else -> New(initialRendering, initialViewEnvironment, showRendering)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if the View WorkflowUiViewState was Started we just recycle it back to New?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that's what you mean by 'possibly replacing the existing one', but it was not clear to me whether you can replace it only before it is Started or you can replace it after as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly about, if there is already a New tag we have to propagate the starter.

That actually happens a lot. When a view factory gets wrapped, this method gets called again to rebind the view to the wrapper rendering type, so repeated calls are the norm.

  • FooScreen : Screen
  • We render NamedScreen(FooScreen())
  • The view is built by FooScreenFactory, which calls bindShowRendering<FooScreen>()
  • NamedScreenFactory invokes FooScreenFactory.buildView, and calls bindShowRendering<NamedScreen<*>>() on the view that FooScreenFactory built.

I actually had a case for is Started<*> that would throw when this was called, but I got afraid of making it difficult to reuse views. I figured just letting it fall through to the else would be more like the current behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add doc to this effect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack. As long as its safe and understood that we could re-use them. Wasn't sure if we needed cleanup hooks before being re-used but I guess this is functionally cleaning up the WorkflowUiViewState which is one good reason to keep it all in that type/tag.

}

/**
* It is usually more convenient to use [WorkflowViewStub] than to call this method directly.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* It is usually more convenient to use [WorkflowViewStub] than to call this method directly.
* This is not necessary when using [WorkflowViewStub] as it will handle this check for you.

private val View.workflowTagAsStarted: Started<*>
get() = workflowTag as? Started<*> ?: error(
"Expected $this to have been started, but View.start() has not been called"
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Goodness me this is the first time I have deeply pined for Swift's protocol extensions.

I get why we need all of this as extensions on the View but it is very difficult to follow the logic with all these (albeit handy) extension shortcuts.

I guess it's not possible (given the coupling) to extend View to a WorkflowManagedView to have all these extensions in a more understandable form?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, this approach is entirely about avoiding the need for custom View classes.

We could create a facade interface that just wrapped a plain old View. I don't think it's worth the effort. Feature developers basically never bump into this stuff, thanks to WorkflowViewStub and such. That will be even more true when the UI overhaul is done, I think.

But just to scratch the nerdsnipe itch, here's a sketch of what that could be.

fun <RenderingT> ViewRegistry.buildView(...): WorkflowView<RenderingT>

interface WorkflowView<RenderingT> {
  fun asView(): View
  
  fun start()

  val rendering: RenderingT
  val environment: ViewEnvironment

  fun update(
    rendering: RenderingT,
    environment: ViewEnvironment
  )
  // ...
}

fun <RenderingT> View.asWorkflowView(): WorkflowView<T>? {
  // Looks for the tag and confirms the type is right? 
}

Still thinking this would be a distraction, but I'm wavering. Dammit Steve, are you proud that you've done this to me?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And WorkflowViewStub could implement WorkflowView<*>! All the stock containers could!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sigh, tracked: #606

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Goodness me this is the first time I have deeply pined for Swift's protocol extensions.

Man, I've been wishing for those since I left Objective-C for Java.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha. Amazing. Very proud. I get your point though that the audience is small. This does make it a lot easier to grok though IMHO.

Comment on lines 103 to 104
* Sets the rendering associated with this view, and displays it by invoking
* the [ViewShowRendering] function previously set by [bindShowRendering].
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Sets the rendering associated with this view, and displays it by invoking
* the [ViewShowRendering] function previously set by [bindShowRendering].
* Shows [rendering] in this View by invoking
* the [ViewShowRendering] function previously set by [bindShowRendering].

I get its setting a tag but that is the implementation detail I think and not necessary to the outcome of the function.

)

@WorkflowUiExperimentalApi
private fun Any.matches(other: Any) = compatible(this, other)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get that maybe this makes it more fluent in showRendering above but adding another layer of indirection is a lot to carry in your mind when grokking.

Not a big deal but maybe can we just use compatible(tag.showing, rendering) above? Is there another use for this?

@rjrjr rjrjr force-pushed the ray/initializeView-is-a-landmine branch from 42d0231 to 0320832 Compare December 10, 2021 21:40
@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 10, 2021

Update addresses @steve-the-edwards review.

  • adds a lot of missing kdoc
  • renames WorkflowUiViewState to WorkflowViewState
  • brings back ViewShowRendering.kt for better diff'ing
  • Moves WorkflowViewState to its own file for better readability.

*
* Intended for use by implementations of [ViewFactory.buildView].
* @throws IllegalStateException when called after [View.start]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not true!

Fixes #597.

It used to be the case that every `DecorativeViewFactory` called
`showRendering()` twice (#397). We fixed that (we thought) by introducing the
`initializeView` lambda to `ViewRegistry.buildView` and
`DecorativeViewFactory` (#408).

Unfortunately, that fix botched recursion. Individual `DecorativeViewFactory`
instances work fine, but if you wrap them you still get one `showRendering`
call from each. Worse, upstream `initializeView` lambdas are clobbered by
immediately downstream ones. e.g., when a `WorkflowViewStub` shows a
`DecorativeViewFactory`, the `WorkflowLifecycleRunner.installOn` call in the
former is clobbered.

The fix is to completely decouple building a view from from this kind of
initialization. `ViewRegistry.buildView` and its wrappers no longer try to
call `showRendering` at all.

Instead the caller of `buildView` (mostly `WorkflowViewStub`) is reponsible
for immediately calling `View.start` on the new `View`. `View.start` makes
the initial `showRendering` call that formerly was the job of
`ViewFactory.buildView` -- the factory builds the view, and the container
turns the key. Since `View.start` is called only after all wrapped
`ViewFactory.buildView` functions have executed, we're certain it will only
happen once.

Of course we still need the ability to customize view initialization via
wrapping, especially to invoke `WorkflowLifecycleOwner.installOn`. To
accomodate that, the function that `View.start` executes can be wrapped via
the new `viewStarter` argument to `ViewRegistry.buildView` and
`DecorativeViewFactory`, which replaces `initializeView`.

This required a pretty thorough overhaul of `ViewShowRendering.kt`
The `ViewShowRenderingTag` that it hangs off of a view tag is renamed
`WorkflowViewState`, and extracted to a separate file.

`WorkflowViewState` is a sealed class with two implementations (`New` and
`Started`) to help us enforce the order of the `ViewRegistry.buildView`,
`View.bindShowRendering`, `View.start` and `View.showRendering` calls.
@rjrjr rjrjr force-pushed the ray/initializeView-is-a-landmine branch from 0320832 to b93a522 Compare December 10, 2021 22:04
@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 10, 2021

21 passed, 24 had a stupid socket error. I'm merging this.

@rjrjr rjrjr merged commit df925e5 into main Dec 10, 2021
@rjrjr rjrjr deleted the ray/initializeView-is-a-landmine branch December 10, 2021 22:44
rjrjr added a commit that referenced this pull request Jan 5, 2022
Propagates changes from #602 -- replace `initializeView`
with `viewStarter` to fix #597.
rjrjr added a commit that referenced this pull request Jan 5, 2022
Propagates changes from #602 -- replace `initializeView`
with `viewStarter` to fix #597.
rjrjr added a commit that referenced this pull request Jan 5, 2022
Propagates changes from #602 -- replace `initializeView`
with `viewStarter` to fix #597.
rjrjr added a commit that referenced this pull request Jan 6, 2022
Propagates changes from #602 -- replace `initializeView`
with `viewStarter` to fix #597.
rjrjr added a commit that referenced this pull request Jan 18, 2022
Our homegrown `View.takeWhileAttached` somehow causes a strange issue on API
30 devices where `View.onAttached` can be called twice, at least since the
recent change to introduce `View.start` landed in #602. We've seen crashes on
a variety of Samsung devices out in the wild, and can reproduce the issue on
API 30 AVDs via Android Studio's _Apply Changes and Restart Activity_.

The fix is to be mainstream and use `Lifecycle`, which add as a new required
parameter to `WorkflowLayout.start`. The old overloads are now deprecated,
and will be deleted soon.
rjrjr added a commit that referenced this pull request Jan 18, 2022
Our homegrown `View.takeWhileAttached` somehow causes a strange issue on API
30 devices where `View.onAttached` can be called twice, at least since the
recent change to introduce `View.start` landed in #602. We've seen crashes on
a variety of Samsung devices out in the wild, and can reproduce the issue on
API 30 AVDs via Android Studio's _Apply Changes and Restart Activity_.

The fix is to be mainstream and use `Lifecycle`, which is added as a new
required parameter to `WorkflowLayout.start`. The old overloads are now
deprecated, and will be deleted soon.
rjrjr added a commit that referenced this pull request Jan 19, 2022
Our sketchy homegrown `View.takeWhileAttached` somehow causes a strange issue
on API 30 devices where `View.onAttached` can be called twice, at least since
the recent change to introduce `View.start` landed in #602. We've seen
crashes on a variety of Samsung devices out in the wild, and can reproduce
the issue on API 30 AVDs via Android Studio's _Apply Changes and Restart
Activity_.

The fix is to be mainstream and use `Lifecycle`, which is added as a new
required parameter to `WorkflowLayout.start`. The old overloads are now
deprecated, and will be deleted soon.

We also promote `WorkflowLayout.show` to be publicly visible, to give apps the
option of taking control over how they collect their renderings.
rjrjr added a commit that referenced this pull request Jan 19, 2022
Our sketchy homegrown `View.takeWhileAttached` somehow causes a strange issue
on API 30 devices where `View.onAttached` can be called twice, at least since
the recent change to introduce `View.start` landed in #602. We've seen
crashes on a variety of Samsung devices out in the wild, and can reproduce
the issue on API 30 AVDs via Android Studio's _Apply Changes and Restart
Activity_.

The fix is to be mainstream and use `Lifecycle`, which is added as a new
required parameter to `WorkflowLayout.start`. The old overloads are now
deprecated, and will be deleted soon.

We also promote `WorkflowLayout.show` to be publicly visible, to give apps the
option of taking control over how they collect their renderings.
rjrjr added a commit that referenced this pull request Jan 19, 2022
Our sketchy homegrown `View.takeWhileAttached` somehow causes a strange issue
on API 30 devices where `View.onAttached` can be called twice, at least since
the recent change to introduce `View.start` landed in #602. We've seen
crashes on a variety of Samsung devices out in the wild, and can reproduce
the issue on API 30 AVDs via Android Studio's _Apply Changes and Restart
Activity_.

The fix is to be mainstream and use `Lifecycle`, which is added as a new
required parameter to `WorkflowLayout.start`. The old overloads are now
deprecated, and will be deleted soon.

We also promote `WorkflowLayout.show` to be publicly visible, to give apps the
option of taking control over how they collect their renderings.
rjrjr added a commit that referenced this pull request Jan 20, 2022
Our sketchy homegrown `View.takeWhileAttached` somehow causes a strange issue
on API 30 devices where `View.onAttached` can be called twice, at least since
the recent change to introduce `View.start` landed in #602. We've seen
crashes on a variety of Samsung devices out in the wild, and can reproduce
the issue on API 30 AVDs via Android Studio's _Apply Changes and Restart
Activity_.

The fix is to be mainstream and use `Lifecycle`, which is added as a new
required parameter to `WorkflowLayout.start`. The old overloads are now
deprecated, and will be deleted soon.

We also promote `WorkflowLayout.show` to be publicly visible, to give apps the
option of taking control over how they collect their renderings.
rjrjr added a commit that referenced this pull request Jan 20, 2022
Our sketchy homegrown `View.takeWhileAttached` somehow causes a strange issue
on API 30 devices where `View.onAttached` can be called twice, at least since
the recent change to introduce `View.start` landed in #602. We've seen
crashes on a variety of Samsung devices out in the wild, and can reproduce
the issue on API 30 AVDs via Android Studio's _Apply Changes and Restart
Activity_.

The fix is to be mainstream and use `Lifecycle`, which is added as a new
required parameter to `WorkflowLayout.start`. The old overloads are now
deprecated, and will be deleted soon.

We also promote `WorkflowLayout.show` to be publicly visible, to give apps the
option of taking control over how they collect their renderings.
rjrjr added a commit that referenced this pull request Jan 20, 2022
Our sketchy homegrown `View.takeWhileAttached` somehow causes a strange issue
on API 30 devices where `View.onAttached` can be called twice, at least since
the recent change to introduce `View.start` landed in #602. We've seen
crashes on a variety of Samsung devices out in the wild, and can reproduce
the issue on API 30 AVDs via Android Studio's _Apply Changes and Restart
Activity_.

The fix is to be mainstream and use `Lifecycle`, which is added as a new
required parameter to `WorkflowLayout.start`. The old overloads are now
deprecated, and will be deleted soon.

We also promote `WorkflowLayout.show` to be publicly visible, to give apps the
option of taking control over how they collect their renderings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Decorative*ViewFactory initializeView clobbers upstream values
3 participants